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:
@@ -38,10 +38,53 @@ const client = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor: attach access token
|
// Decode JWT payload without external library
|
||||||
client.interceptors.request.use((config) => {
|
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');
|
const token = localStorage.getItem('access_token');
|
||||||
if (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}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ export async function getPluginSchema(id: string): Promise<PluginSchemaResponse>
|
|||||||
|
|
||||||
// ── Schema 类型定义 ──
|
// ── Schema 类型定义 ──
|
||||||
|
|
||||||
|
export interface PluginFieldValidation {
|
||||||
|
pattern?: string;
|
||||||
|
message?: string;
|
||||||
|
min_length?: number;
|
||||||
|
max_length?: number;
|
||||||
|
min_value?: number;
|
||||||
|
max_value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginFieldSchema {
|
export interface PluginFieldSchema {
|
||||||
name: string;
|
name: string;
|
||||||
field_type: string;
|
field_type: string;
|
||||||
@@ -132,12 +141,24 @@ export interface PluginFieldSchema {
|
|||||||
ref_search_fields?: string[];
|
ref_search_fields?: string[];
|
||||||
cascade_from?: string;
|
cascade_from?: string;
|
||||||
cascade_filter?: 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 {
|
export interface PluginEntitySchema {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
fields: PluginFieldSchema[];
|
fields: PluginFieldSchema[];
|
||||||
|
relations?: PluginRelationSchema[];
|
||||||
|
data_scope?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginSchemaResponse {
|
export interface PluginSchemaResponse {
|
||||||
|
|||||||
@@ -127,8 +127,15 @@ export default function Home() {
|
|||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const extractTotal = (res: PromiseSettledResult<{ data: { data?: { total?: number } } }>) =>
|
const extractTotal = (res: PromiseSettledResult<{ data: { data?: { total?: number } } }>) => {
|
||||||
res.status === 'fulfilled' ? (res.value.data?.data?.total ?? 0) : 0;
|
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({
|
setStats({
|
||||||
userCount: extractTotal(usersRes),
|
userCount: extractTotal(usersRes),
|
||||||
@@ -147,7 +154,8 @@ export default function Home() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [fetchUnreadCount]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleNavigate = useCallback((path: string) => {
|
const handleNavigate = useCallback((path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
@@ -220,10 +228,10 @@ export default function Home() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const recentActivities: ActivityItem[] = [
|
const recentActivities: ActivityItem[] = [
|
||||||
{ id: '1', text: '系统管理员 创建了 <strong>管理员角色</strong>', time: '刚刚', icon: <TeamOutlined /> },
|
{ id: '1', text: '系统管理员 创建了 管理员角色', time: '刚刚', icon: <TeamOutlined /> },
|
||||||
{ id: '2', text: '系统管理员 配置了 <strong>工作流模板</strong>', time: '5 分钟前', icon: <FileProtectOutlined /> },
|
{ id: '2', text: '系统管理员 配置了 工作流模板', time: '5 分钟前', icon: <FileProtectOutlined /> },
|
||||||
{ id: '3', text: '系统管理员 更新了 <strong>组织架构</strong>', time: '10 分钟前', icon: <ApartmentOutlined /> },
|
{ id: '3', text: '系统管理员 更新了 组织架构', time: '10 分钟前', icon: <ApartmentOutlined /> },
|
||||||
{ id: '4', text: '系统管理员 设置了 <strong>消息通知偏好</strong>', time: '30 分钟前', icon: <BellOutlined /> },
|
{ id: '4', text: '系统管理员 设置了 消息通知偏好', time: '30 分钟前', icon: <BellOutlined /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const priorityLabel: Record<string, string> = { high: '紧急', medium: '一般', low: '低' };
|
const priorityLabel: Record<string, string> = { high: '紧急', medium: '一般', low: '低' };
|
||||||
@@ -351,7 +359,7 @@ export default function Home() {
|
|||||||
<div key={activity.id} className="erp-activity-item">
|
<div key={activity.id} className="erp-activity-item">
|
||||||
<div className="erp-activity-dot">{activity.icon}</div>
|
<div className="erp-activity-dot">{activity.icon}</div>
|
||||||
<div className="erp-activity-content">
|
<div className="erp-activity-content">
|
||||||
<div className="erp-activity-text" dangerouslySetInnerHTML={{ __html: activity.text }} />
|
<div className="erp-activity-text">{activity.text}</div>
|
||||||
<div className="erp-activity-time">{activity.time}</div>
|
<div className="erp-activity-time">{activity.time}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type DashboardWidget,
|
type DashboardWidget,
|
||||||
} from '../api/plugins';
|
} from '../api/plugins';
|
||||||
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes';
|
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 {
|
import {
|
||||||
StatCard,
|
StatCard,
|
||||||
SkeletonStatCard,
|
SkeletonStatCard,
|
||||||
@@ -89,27 +89,28 @@ export function PluginDashboardPage() {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
async function loadAllCounts() {
|
async function loadAllCounts() {
|
||||||
const results: EntityStat[] = [];
|
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;
|
if (abortController.signal.aborted) return;
|
||||||
|
const palette = getEntityPalette(entity.name, i);
|
||||||
|
const icon = getEntityIcon(entity.name);
|
||||||
try {
|
try {
|
||||||
const count = await countPluginData(pluginId!, entity.name);
|
const count = await countPluginData(pluginId!, entity.name);
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
|
||||||
results.push({
|
results.push({
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
displayName: entity.display_name || entity.name,
|
displayName: entity.display_name || entity.name,
|
||||||
count,
|
count,
|
||||||
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
icon,
|
||||||
gradient: palette.gradient,
|
gradient: palette.gradient,
|
||||||
iconBg: palette.iconBg,
|
iconBg: palette.iconBg,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
|
|
||||||
results.push({
|
results.push({
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
displayName: entity.display_name || entity.name,
|
displayName: entity.display_name || entity.name,
|
||||||
count: 0,
|
count: 0,
|
||||||
icon: ENTITY_ICONS[entity.name] || <DashboardOutlined />,
|
icon,
|
||||||
gradient: palette.gradient,
|
gradient: palette.gradient,
|
||||||
iconBg: palette.iconBg,
|
iconBg: palette.iconBg,
|
||||||
});
|
});
|
||||||
@@ -206,8 +207,8 @@ export function PluginDashboardPage() {
|
|||||||
);
|
);
|
||||||
// 当前实体的色板
|
// 当前实体的色板
|
||||||
const currentPalette = useMemo(
|
const currentPalette = useMemo(
|
||||||
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
|
() => getEntityPalette(selectedEntity, entities.findIndex((e) => e.name === selectedEntity)),
|
||||||
[selectedEntity],
|
[selectedEntity, entities],
|
||||||
);
|
);
|
||||||
// ── 渲染 ──
|
// ── 渲染 ──
|
||||||
if (schemaLoading) {
|
if (schemaLoading) {
|
||||||
@@ -257,7 +258,7 @@ export function PluginDashboardPage() {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
CRM 数据全景视图,实时掌握业务动态
|
{pluginId ? `${pluginId.toUpperCase()} 数据统计` : '数据统计'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
import type { GraphNode, GraphEdge, GraphConfig, NodePosition, HoverState } from './graph/graphTypes';
|
import type { GraphNode, GraphEdge, GraphConfig, NodePosition, HoverState } from './graph/graphTypes';
|
||||||
import { computeCircularLayout } from './graph/graphLayout';
|
import { computeCircularLayout } from './graph/graphLayout';
|
||||||
import {
|
import {
|
||||||
RELATIONSHIP_COLORS,
|
getEdgeColor,
|
||||||
NODE_HOVER_SCALE,
|
NODE_HOVER_SCALE,
|
||||||
getRelColor,
|
getRelColor,
|
||||||
getEdgeTypeLabel,
|
getEdgeTypeLabel,
|
||||||
@@ -327,8 +327,7 @@ export function PluginGraphPage() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const idx = nodes.indexOf(node);
|
const idx = nodes.indexOf(node);
|
||||||
const palette = Object.values(RELATIONSHIP_COLORS);
|
const pick = getEdgeColor(`_node_${idx}`);
|
||||||
const pick = palette[idx % palette.length];
|
|
||||||
nodeColorBase = pick.base;
|
nodeColorBase = pick.base;
|
||||||
nodeColorLight = pick.light;
|
nodeColorLight = pick.light;
|
||||||
nodeColorGlow = pick.glow;
|
nodeColorGlow = pick.glow;
|
||||||
@@ -475,7 +474,7 @@ export function PluginGraphPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
<Spin size="large" tip="加载图谱数据中..." />
|
<Spin size="large" description="加载图谱数据中..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -497,7 +496,7 @@ export function PluginGraphPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
value={customers.length}
|
value={customers.length}
|
||||||
valueStyle={{ color: token.colorPrimary, fontWeight: 600 }}
|
styles={{ content: { color: token.colorPrimary, fontWeight: 600 } }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -514,7 +513,7 @@ export function PluginGraphPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
value={relationships.length}
|
value={relationships.length}
|
||||||
valueStyle={{ color: token.colorSuccess, fontWeight: 600 }}
|
styles={{ content: { color: token.colorSuccess, fontWeight: 600 } }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -531,10 +530,12 @@ export function PluginGraphPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
value={centerNode?.label || '未选择'}
|
value={centerNode?.label || '未选择'}
|
||||||
valueStyle={{
|
styles={{
|
||||||
fontSize: 20,
|
content: {
|
||||||
color: centerNode ? token.colorWarning : token.colorTextDisabled,
|
fontSize: 20,
|
||||||
fontWeight: 600,
|
color: centerNode ? token.colorWarning : token.colorTextDisabled,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{selectedCenter && (
|
{selectedCenter && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
Button,
|
Button,
|
||||||
@@ -80,6 +80,15 @@ export default function Users() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [page, searchText]);
|
}, [page, searchText]);
|
||||||
|
|
||||||
|
// 搜索防抖:输入后 300ms 才触发查询
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const debouncedSearch = useCallback((_text: string) => {
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchRoles = useCallback(async () => {
|
const fetchRoles = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await listRoles();
|
const result = await listRoles();
|
||||||
@@ -349,7 +358,10 @@ export default function Users() {
|
|||||||
placeholder="搜索用户名..."
|
placeholder="搜索用户名..."
|
||||||
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
debouncedSearch(e.target.value);
|
||||||
|
}}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 220, borderRadius: 8 }}
|
style={{ width: 220, borderRadius: 8 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function BreakdownCard({
|
|||||||
percent={barPercent}
|
percent={barPercent}
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
strokeColor={tagStrokeColor(color)}
|
strokeColor={tagStrokeColor(color)}
|
||||||
trailColor="var(--erp-border-light)"
|
railColor="var(--erp-border-light)"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginBottom: 0 }}
|
style={{ marginBottom: 0 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,37 +9,25 @@ import {
|
|||||||
PieChartOutlined,
|
PieChartOutlined,
|
||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
FunnelPlotOutlined,
|
FunnelPlotOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
ShoppingOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
// ── 色板配置 ──
|
// ── 通用调色板 ──
|
||||||
|
|
||||||
export const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
|
const UNIVERSAL_COLORS = [
|
||||||
customer: {
|
{ gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', iconBg: 'rgba(79, 70, 229, 0.12)', tagColor: 'purple' },
|
||||||
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
{ gradient: 'linear-gradient(135deg, #059669, #10B981)', iconBg: 'rgba(5, 150, 105, 0.12)', tagColor: 'green' },
|
||||||
iconBg: 'rgba(79, 70, 229, 0.12)',
|
{ gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', iconBg: 'rgba(217, 119, 6, 0.12)', tagColor: 'orange' },
|
||||||
tagColor: 'purple',
|
{ gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', iconBg: 'rgba(124, 58, 237, 0.12)', tagColor: 'volcano' },
|
||||||
},
|
{ gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', iconBg: 'rgba(225, 29, 72, 0.12)', tagColor: 'red' },
|
||||||
contact: {
|
{ gradient: 'linear-gradient(135deg, #0891B2, #06B6D4)', iconBg: 'rgba(8, 145, 178, 0.12)', tagColor: 'cyan' },
|
||||||
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
{ gradient: 'linear-gradient(135deg, #EA580C, #F97316)', iconBg: 'rgba(234, 88, 12, 0.12)', tagColor: 'orange' },
|
||||||
iconBg: 'rgba(5, 150, 105, 0.12)',
|
{ gradient: 'linear-gradient(135deg, #DB2777, #EC4899)', iconBg: 'rgba(219, 39, 119, 0.12)', tagColor: 'magenta' },
|
||||||
tagColor: 'green',
|
];
|
||||||
},
|
|
||||||
communication: {
|
|
||||||
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
|
|
||||||
iconBg: 'rgba(217, 119, 6, 0.12)',
|
|
||||||
tagColor: 'orange',
|
|
||||||
},
|
|
||||||
customer_tag: {
|
|
||||||
gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)',
|
|
||||||
iconBg: 'rgba(124, 58, 237, 0.12)',
|
|
||||||
tagColor: 'volcano',
|
|
||||||
},
|
|
||||||
customer_relationship: {
|
|
||||||
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
|
|
||||||
iconBg: 'rgba(225, 29, 72, 0.12)',
|
|
||||||
tagColor: 'red',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_PALETTE = {
|
export const DEFAULT_PALETTE = {
|
||||||
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
||||||
@@ -52,16 +40,42 @@ export const TAG_COLORS = [
|
|||||||
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── 图标映射 ──
|
// ── 按实体顺序自动分配颜色 ──
|
||||||
|
|
||||||
export const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
const paletteCache = new Map<string, { gradient: string; iconBg: string; tagColor: string }>();
|
||||||
customer: <TeamOutlined />,
|
|
||||||
contact: <TeamOutlined />,
|
export function getEntityPalette(entityName: string, index: number) {
|
||||||
communication: <PhoneOutlined />,
|
const cached = paletteCache.get(entityName);
|
||||||
customer_tag: <TagsOutlined />,
|
if (cached) return cached;
|
||||||
customer_relationship: <RiseOutlined />,
|
const safeIndex = index >= 0 ? index : 0;
|
||||||
|
const palette = UNIVERSAL_COLORS[safeIndex % UNIVERSAL_COLORS.length];
|
||||||
|
paletteCache.set(entityName, palette);
|
||||||
|
return palette;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通用图标映射 ──
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
|
team: <TeamOutlined />,
|
||||||
|
user: <UserOutlined />,
|
||||||
|
users: <TeamOutlined />,
|
||||||
|
message: <PhoneOutlined />,
|
||||||
|
phone: <PhoneOutlined />,
|
||||||
|
tags: <TagsOutlined />,
|
||||||
|
tag: <TagsOutlined />,
|
||||||
|
apartment: <RiseOutlined />,
|
||||||
|
dashboard: <DashboardOutlined />,
|
||||||
|
shopping: <ShoppingOutlined />,
|
||||||
|
file: <FileTextOutlined />,
|
||||||
|
database: <DatabaseOutlined />,
|
||||||
|
appstore: <AppstoreOutlined />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getEntityIcon(iconName?: string): React.ReactNode {
|
||||||
|
if (!iconName) return <AppstoreOutlined />;
|
||||||
|
return ICON_MAP[iconName.toLowerCase().replace('outlined', '')] ?? <AppstoreOutlined />;
|
||||||
|
}
|
||||||
|
|
||||||
export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
||||||
stat_card: <DashboardOutlined />,
|
stat_card: <DashboardOutlined />,
|
||||||
bar_chart: <BarChartOutlined />,
|
bar_chart: <BarChartOutlined />,
|
||||||
|
|||||||
@@ -9,26 +9,45 @@ import type { GraphEdge } from './graphTypes';
|
|||||||
|
|
||||||
// ── 常量 ──
|
// ── 常量 ──
|
||||||
|
|
||||||
/** 关系类型对应的色板 (base / light / glow) */
|
/** 关系类型对应的色板 (base / light / glow) — 通用调色板自动分配 */
|
||||||
export const RELATIONSHIP_COLORS: Record<string, { base: string; light: string; glow: string }> = {
|
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
|
||||||
parent_child: { base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' },
|
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' },
|
||||||
sibling: { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
|
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
|
||||||
partner: { base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
|
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
|
||||||
supplier: { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
|
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
|
||||||
competitor: { base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
|
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
|
||||||
};
|
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
|
||||||
|
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
|
||||||
|
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },
|
||||||
|
];
|
||||||
|
|
||||||
/** 未匹配到已知关系类型时的默认色 */
|
/** 未匹配到已知关系类型时的默认色 */
|
||||||
export const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' };
|
export const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' };
|
||||||
|
|
||||||
/** 关系类型 → 中文标签 */
|
const edgeColorCache = new Map<string, { base: string; light: string; glow: string }>();
|
||||||
export const REL_LABEL_MAP: Record<string, string> = {
|
|
||||||
parent_child: '母子',
|
export function getEdgeColorGlobal(label: string) {
|
||||||
sibling: '兄弟',
|
const cached = edgeColorCache.get(label);
|
||||||
partner: '伙伴',
|
if (cached) return cached;
|
||||||
supplier: '供应商',
|
const color = EDGE_PALETTE[edgeColorCache.size % EDGE_PALETTE.length];
|
||||||
competitor: '竞争',
|
edgeColorCache.set(label, color);
|
||||||
};
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 getEdgeColorGlobal */
|
||||||
|
export const RELATIONSHIP_COLORS = new Proxy({} as Record<string, { base: string; light: string; glow: string }>, {
|
||||||
|
get(_, prop: string) { return getEdgeColorGlobal(prop); },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @deprecated 标签直接使用原始值 */
|
||||||
|
export const REL_LABEL_MAP = new Proxy({} as Record<string, string>, {
|
||||||
|
get(_, prop: string) { return prop; },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 通用边颜色函数 — 兼容旧路径导入 */
|
||||||
|
export function getEdgeColor(label: string) {
|
||||||
|
return getEdgeColorGlobal(label);
|
||||||
|
}
|
||||||
|
|
||||||
/** 普通节点基础半径 */
|
/** 普通节点基础半径 */
|
||||||
export const NODE_BASE_RADIUS = 18;
|
export const NODE_BASE_RADIUS = 18;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface PluginStore {
|
|||||||
refreshMenuItems: () => void;
|
refreshMenuItems: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 请求去重:防止并发重复调用 fetchPlugins
|
||||||
|
let fetchPluginsPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export const usePluginStore = create<PluginStore>((set, get) => ({
|
export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||||
plugins: [],
|
plugins: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -35,10 +38,16 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
|||||||
schemaCache: {},
|
schemaCache: {},
|
||||||
|
|
||||||
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
fetchPlugins: async (page = 1, status?: PluginStatus) => {
|
||||||
set({ loading: true });
|
// 如果已有进行中的请求,复用该 Promise
|
||||||
try {
|
if (fetchPluginsPromise) {
|
||||||
const result = await listPlugins(page, 100, status);
|
await fetchPluginsPromise;
|
||||||
set({ plugins: result.data });
|
return;
|
||||||
|
}
|
||||||
|
fetchPluginsPromise = (async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const result = await listPlugins(page, 100, status);
|
||||||
|
set({ plugins: result.data });
|
||||||
|
|
||||||
// 先基于 entities 生成回退菜单,确保侧边栏快速渲染
|
// 先基于 entities 生成回退菜单,确保侧边栏快速渲染
|
||||||
get().refreshMenuItems();
|
get().refreshMenuItems();
|
||||||
@@ -68,9 +77,12 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
set({ schemaCache: schemas });
|
set({ schemaCache: schemas });
|
||||||
get().refreshMenuItems();
|
get().refreshMenuItems();
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
}
|
fetchPluginsPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
await fetchPluginsPromise;
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshMenuItems: () => {
|
refreshMenuItems: () => {
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ impl IntoResponse for AppError {
|
|||||||
AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
AppError::VersionMismatch => (StatusCode::CONFLICT, self.to_string()),
|
AppError::VersionMismatch => (StatusCode::CONFLICT, self.to_string()),
|
||||||
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
|
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
|
||||||
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string()),
|
AppError::Internal(msg) => {
|
||||||
|
tracing::error!("Internal error: {}", msg);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string())
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = ErrorResponse {
|
let body = ErrorResponse {
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ impl PluginDataService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 构建数据权限条件
|
// 构建数据权限条件(count 查询只有 tenant_id 占 $1,scope 从 $2 开始)
|
||||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields);
|
let count_scope = build_scope_sql(&scope, &info.generated_fields, 2);
|
||||||
|
|
||||||
// Count
|
// Count
|
||||||
let (count_sql, mut count_values) =
|
let (count_sql, mut count_values) =
|
||||||
DynamicTableManager::build_count_sql(&info.table_name, tenant_id);
|
DynamicTableManager::build_count_sql(&info.table_name, tenant_id);
|
||||||
let count_sql = merge_scope_condition(count_sql, &scope_condition);
|
let count_sql = merge_scope_condition(count_sql, &count_scope);
|
||||||
count_values.extend(scope_condition.1.clone());
|
count_values.extend(count_scope.1);
|
||||||
|
|
||||||
#[derive(FromQueryResult)]
|
#[derive(FromQueryResult)]
|
||||||
struct CountResult {
|
struct CountResult {
|
||||||
@@ -147,7 +147,8 @@ impl PluginDataService {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Validation(e))?;
|
.map_err(|e| AppError::Validation(e))?;
|
||||||
|
|
||||||
// 注入数据权限条件
|
// 注入数据权限条件(scope 参数索引接在 values 之后)
|
||||||
|
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||||
let sql = merge_scope_condition(sql, &scope_condition);
|
let sql = merge_scope_condition(sql, &scope_condition);
|
||||||
values.extend(scope_condition.1);
|
values.extend(scope_condition.1);
|
||||||
|
|
||||||
@@ -299,6 +300,22 @@ impl PluginDataService {
|
|||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
) -> AppResult<PluginDataResp> {
|
) -> AppResult<PluginDataResp> {
|
||||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||||
|
let fields = info.fields()?;
|
||||||
|
|
||||||
|
// 合并现有数据后校验,确保 partial update 也能触发 required/pattern/ref 校验
|
||||||
|
let existing = Self::get_by_id(plugin_id, entity_name, id, tenant_id, db).await?;
|
||||||
|
let merged = {
|
||||||
|
let mut base = existing.data.as_object().cloned().unwrap_or_default();
|
||||||
|
if let Some(patch) = partial_data.as_object() {
|
||||||
|
for (k, v) in patch {
|
||||||
|
base.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(base)
|
||||||
|
};
|
||||||
|
|
||||||
|
validate_data(&merged, &fields)?;
|
||||||
|
validate_ref_entities(&merged, &fields, entity_name, plugin_id, tenant_id, db, false, Some(id)).await?;
|
||||||
|
|
||||||
let (sql, values) = DynamicTableManager::build_patch_sql(
|
let (sql, values) = DynamicTableManager::build_patch_sql(
|
||||||
&info.table_name, id, tenant_id, operator_id, partial_data, expected_version,
|
&info.table_name, id, tenant_id, operator_id, partial_data, expected_version,
|
||||||
@@ -440,6 +457,62 @@ impl PluginDataService {
|
|||||||
|
|
||||||
let affected = match req.action.as_str() {
|
let affected = match req.action.as_str() {
|
||||||
"batch_delete" => {
|
"batch_delete" => {
|
||||||
|
// 批量删除前先执行级联策略(逐条,复用 delete 的级联逻辑)
|
||||||
|
let entity_def: crate::manifest::PluginEntity =
|
||||||
|
serde_json::from_value(info.schema_json.clone())
|
||||||
|
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||||
|
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
for &del_id in &ids {
|
||||||
|
for relation in &entity_def.relations {
|
||||||
|
let rel_table = DynamicTableManager::table_name(&manifest_id, &relation.entity);
|
||||||
|
let fk = sanitize_identifier(&relation.foreign_key);
|
||||||
|
match relation.on_delete {
|
||||||
|
crate::manifest::OnDeleteStrategy::Restrict => {
|
||||||
|
let check_sql = format!(
|
||||||
|
"SELECT 1 as chk FROM \"{}\" WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||||||
|
rel_table, fk
|
||||||
|
);
|
||||||
|
#[derive(FromQueryResult)]
|
||||||
|
struct RefCheck { chk: Option<i32> }
|
||||||
|
let has_ref = RefCheck::find_by_statement(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
check_sql,
|
||||||
|
[del_id.to_string().into(), tenant_id.into()],
|
||||||
|
)).one(db).await?;
|
||||||
|
if has_ref.is_some() {
|
||||||
|
return Err(AppError::Validation(format!(
|
||||||
|
"记录 {} 存在关联的 {} 记录,无法删除",
|
||||||
|
del_id, relation.entity
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::manifest::OnDeleteStrategy::Nullify => {
|
||||||
|
let nullify_sql = format!(
|
||||||
|
"UPDATE \"{}\" SET data = jsonb_set(data, '{{{}}}', 'null'), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||||
|
rel_table, fk, fk
|
||||||
|
);
|
||||||
|
db.execute(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
nullify_sql,
|
||||||
|
[del_id.to_string().into(), tenant_id.into()],
|
||||||
|
)).await?;
|
||||||
|
}
|
||||||
|
crate::manifest::OnDeleteStrategy::Cascade => {
|
||||||
|
let cascade_sql = format!(
|
||||||
|
"UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||||
|
rel_table, fk
|
||||||
|
);
|
||||||
|
db.execute(Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
cascade_sql,
|
||||||
|
[del_id.to_string().into(), tenant_id.into()],
|
||||||
|
)).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let placeholders: Vec<String> = ids
|
let placeholders: Vec<String> = ids
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -553,7 +626,7 @@ impl PluginDataService {
|
|||||||
.map_err(|e| AppError::Validation(e))?;
|
.map_err(|e| AppError::Validation(e))?;
|
||||||
|
|
||||||
// 合并数据权限条件
|
// 合并数据权限条件
|
||||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields);
|
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||||
if !scope_condition.0.is_empty() {
|
if !scope_condition.0.is_empty() {
|
||||||
sql = merge_scope_condition(sql, &scope_condition);
|
sql = merge_scope_condition(sql, &scope_condition);
|
||||||
values.extend(scope_condition.1);
|
values.extend(scope_condition.1);
|
||||||
@@ -599,7 +672,7 @@ impl PluginDataService {
|
|||||||
.map_err(|e| AppError::Validation(e))?;
|
.map_err(|e| AppError::Validation(e))?;
|
||||||
|
|
||||||
// 合并数据权限条件
|
// 合并数据权限条件
|
||||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields);
|
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||||
if !scope_condition.0.is_empty() {
|
if !scope_condition.0.is_empty() {
|
||||||
sql = merge_scope_condition(sql, &scope_condition);
|
sql = merge_scope_condition(sql, &scope_condition);
|
||||||
values.extend(scope_condition.1);
|
values.extend(scope_condition.1);
|
||||||
@@ -665,7 +738,7 @@ impl PluginDataService {
|
|||||||
.map_err(|e| AppError::Validation(e))?;
|
.map_err(|e| AppError::Validation(e))?;
|
||||||
|
|
||||||
// 合并数据权限条件
|
// 合并数据权限条件
|
||||||
let scope_condition = build_scope_sql(&scope, &info.generated_fields);
|
let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1);
|
||||||
if !scope_condition.0.is_empty() {
|
if !scope_condition.0.is_empty() {
|
||||||
sql = merge_scope_condition(sql, &scope_condition);
|
sql = merge_scope_condition(sql, &scope_condition);
|
||||||
values.extend(scope_condition.1);
|
values.extend(scope_condition.1);
|
||||||
@@ -958,6 +1031,7 @@ async fn check_no_cycle(
|
|||||||
fn build_scope_sql(
|
fn build_scope_sql(
|
||||||
scope: &Option<DataScopeParams>,
|
scope: &Option<DataScopeParams>,
|
||||||
generated_fields: &[String],
|
generated_fields: &[String],
|
||||||
|
next_param_idx: usize,
|
||||||
) -> (String, Vec<sea_orm::Value>) {
|
) -> (String, Vec<sea_orm::Value>) {
|
||||||
match scope {
|
match scope {
|
||||||
Some(s) => DynamicTableManager::build_data_scope_condition_with_params(
|
Some(s) => DynamicTableManager::build_data_scope_condition_with_params(
|
||||||
@@ -965,7 +1039,7 @@ fn build_scope_sql(
|
|||||||
&s.user_id,
|
&s.user_id,
|
||||||
&s.owner_field,
|
&s.owner_field,
|
||||||
&s.dept_member_ids,
|
&s.dept_member_ids,
|
||||||
100, // 起始参数索引(远大于实际参数数量,避免冲突;后续重新编号)
|
next_param_idx,
|
||||||
generated_fields,
|
generated_fields,
|
||||||
),
|
),
|
||||||
None => (String::new(), vec![]),
|
None => (String::new(), vec![]),
|
||||||
|
|||||||
Reference in New Issue
Block a user