Compare commits
30 Commits
3b0b78c4cb
...
b96978b588
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96978b588 | ||
|
|
fb809f124c | ||
|
|
60799176ca | ||
|
|
4ea9bccba6 | ||
|
|
9549f896b6 | ||
|
|
a333b3673f | ||
|
|
c9a58e9d34 | ||
|
|
c487a94f19 | ||
|
|
022ac951c9 | ||
|
|
b0ee3e495d | ||
|
|
e2e58d3a00 | ||
|
|
5b2ae16ffb | ||
|
|
8bef5e2401 | ||
|
|
a7342f83e9 | ||
|
|
41a0dc8bd6 | ||
|
|
89684313d9 | ||
|
|
e24b820d80 | ||
|
|
e6aaa18ceb | ||
|
|
314580243e | ||
|
|
dadb826804 | ||
|
|
649334e862 | ||
|
|
f4b1a06d53 | ||
|
|
527a57df9e | ||
|
|
62f17d13ad | ||
|
|
6f286acbeb | ||
|
|
f697b5fd6d | ||
|
|
abc3086571 | ||
|
|
16b7a36bfb | ||
|
|
28c7126518 | ||
|
|
091d517af6 |
41
Cargo.lock
generated
41
Cargo.lock
generated
@@ -901,6 +901,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -1234,9 +1243,11 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"erp-core",
|
||||
"moka",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1297,6 +1308,7 @@ dependencies = [
|
||||
"erp-plugin",
|
||||
"erp-server-migration",
|
||||
"erp-workflow",
|
||||
"moka",
|
||||
"redis",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -2334,6 +2346,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"equivalent",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
@@ -2684,6 +2713,12 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -3964,6 +3999,12 @@ dependencies = [
|
||||
"winx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tap"
|
||||
version = "1.0.1"
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"antd": "^6.3.5",
|
||||
"axios": "^1.15.0",
|
||||
|
||||
900
apps/web/pnpm-lock.yaml
generated
900
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => (
|
||||
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
|
||||
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
|
||||
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
|
||||
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
@@ -146,6 +147,7 @@ export default function App() {
|
||||
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
||||
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
|
||||
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
|
||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -123,3 +123,51 @@ export async function aggregatePluginData(
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── 批量操作 ──
|
||||
|
||||
export async function batchPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
req: { action: string; ids: string[]; data?: Record<string, unknown> },
|
||||
) {
|
||||
const { data } = await client.post<{ success: boolean; data: unknown }>(
|
||||
`/plugins/${pluginId}/${entity}/batch`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── 部分更新 ──
|
||||
|
||||
export async function patchPluginData(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
id: string,
|
||||
req: { data: Record<string, unknown>; version: number },
|
||||
) {
|
||||
const { data } = await client.patch<{ success: boolean; data: PluginDataRecord }>(
|
||||
`/plugins/${pluginId}/${entity}/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ── 时间序列 ──
|
||||
|
||||
export async function getPluginTimeseries(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
params: {
|
||||
time_field: string;
|
||||
time_grain: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
},
|
||||
) {
|
||||
const { data } = await client.get<{ success: boolean; data: unknown }>(
|
||||
`/plugins/${pluginId}/${entity}/timeseries`,
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,11 @@ export interface PluginFieldSchema {
|
||||
sortable?: boolean;
|
||||
visible_when?: string;
|
||||
unique?: boolean;
|
||||
ref_entity?: string;
|
||||
ref_label_field?: string;
|
||||
ref_search_fields?: string[];
|
||||
cascade_from?: string;
|
||||
cascade_filter?: string;
|
||||
}
|
||||
|
||||
export interface PluginEntitySchema {
|
||||
@@ -157,7 +162,30 @@ export type PluginPageSchema =
|
||||
| { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] }
|
||||
| { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }
|
||||
| { type: 'graph'; entity: string; label: string; relationship_entity: string; source_field: string; target_field: string; edge_label_field: string; node_label_field: string }
|
||||
| { type: 'dashboard'; label: string };
|
||||
| { type: 'dashboard'; label: string; widgets?: DashboardWidget[] }
|
||||
| {
|
||||
type: 'kanban';
|
||||
entity: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
lane_field: string;
|
||||
lane_order?: string[];
|
||||
card_title_field: string;
|
||||
card_subtitle_field?: string;
|
||||
card_fields?: string[];
|
||||
enable_drag?: boolean;
|
||||
};
|
||||
|
||||
export interface DashboardWidget {
|
||||
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart';
|
||||
entity: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
dimension_field?: string;
|
||||
dimension_order?: string[];
|
||||
metric?: string;
|
||||
}
|
||||
|
||||
export type PluginSectionSchema =
|
||||
| { type: 'fields'; label: string; fields: string[] }
|
||||
|
||||
80
apps/web/src/components/EntitySelect.tsx
Normal file
80
apps/web/src/components/EntitySelect.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { listPluginData } from '../api/pluginData';
|
||||
|
||||
interface EntitySelectProps {
|
||||
pluginId: string;
|
||||
entity: string;
|
||||
labelField: string;
|
||||
searchFields?: string[];
|
||||
value?: string;
|
||||
onChange?: (value: string, label: string) => void;
|
||||
cascadeFrom?: string;
|
||||
cascadeFilter?: string;
|
||||
cascadeValue?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function EntitySelect({
|
||||
pluginId,
|
||||
entity,
|
||||
labelField,
|
||||
searchFields: _searchFields,
|
||||
value,
|
||||
onChange,
|
||||
cascadeFrom,
|
||||
cascadeFilter,
|
||||
cascadeValue,
|
||||
placeholder,
|
||||
}: EntitySelectProps) {
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (keyword?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filter: Record<string, string> | undefined =
|
||||
cascadeFrom && cascadeFilter && cascadeValue
|
||||
? { [cascadeFilter]: cascadeValue }
|
||||
: undefined;
|
||||
|
||||
const result = await listPluginData(pluginId, entity, 1, 20, {
|
||||
search: keyword,
|
||||
filter,
|
||||
});
|
||||
|
||||
const items = (result.data || []).map((item) => ({
|
||||
value: item.id,
|
||||
label: String(item.data?.[labelField] ?? item.id),
|
||||
}));
|
||||
setOptions(items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[pluginId, entity, labelField, cascadeFrom, cascadeFilter, cascadeValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
value={value}
|
||||
placeholder={placeholder || '请选择'}
|
||||
loading={loading}
|
||||
options={options}
|
||||
onSearch={(v) => fetchData(v)}
|
||||
onChange={(v) => {
|
||||
const opt = options.find((o) => o.value === v);
|
||||
onChange?.(v, opt?.label || '');
|
||||
}}
|
||||
filterOption={false}
|
||||
notFoundContent={loading ? <Spin size="small" /> : '无数据'}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,10 @@ import {
|
||||
createPluginData,
|
||||
updatePluginData,
|
||||
deletePluginData,
|
||||
batchPluginData,
|
||||
type PluginDataListOptions,
|
||||
} from '../api/pluginData';
|
||||
import EntitySelect from '../components/EntitySelect';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginFieldSchema,
|
||||
@@ -40,29 +42,11 @@ import {
|
||||
type PluginPageSchema,
|
||||
type PluginSectionSchema,
|
||||
} from '../api/plugins';
|
||||
import { evaluateVisibleWhen } from '../utils/exprEvaluator';
|
||||
|
||||
const { Search } = Input;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/** visible_when 表达式解析 */
|
||||
function parseVisibleWhen(expression: string): { field: string; value: string } | null {
|
||||
const regex = /^(\w+)\s*==\s*'([^']*)'$/;
|
||||
const match = expression.trim().match(regex);
|
||||
if (!match) return null;
|
||||
return { field: match[1], value: match[2] };
|
||||
}
|
||||
|
||||
/** 判断字段是否应该显示 */
|
||||
function shouldShowField(
|
||||
allValues: Record<string, unknown>,
|
||||
visibleWhen: string | undefined,
|
||||
): boolean {
|
||||
if (!visibleWhen) return true;
|
||||
const parsed = parseVisibleWhen(visibleWhen);
|
||||
if (!parsed) return true;
|
||||
return String(allValues[parsed.field] ?? '') === parsed.value;
|
||||
}
|
||||
|
||||
interface PluginCRUDPageProps {
|
||||
/** 如果从 tabs/detail 页面内嵌使用,通过 props 传入配置 */
|
||||
pluginIdOverride?: string;
|
||||
@@ -106,6 +90,9 @@ export default function PluginCRUDPage({
|
||||
// 视图切换
|
||||
const [viewMode, setViewMode] = useState<string>('table');
|
||||
|
||||
// 批量选择
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
// 详情 Drawer
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
||||
@@ -257,6 +244,21 @@ export default function PluginCRUDPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (!pluginId || !entityName || selectedRowKeys.length === 0) return;
|
||||
try {
|
||||
await batchPluginData(pluginId, entityName, {
|
||||
action: 'delete',
|
||||
ids: selectedRowKeys,
|
||||
});
|
||||
message.success(`已删除 ${selectedRowKeys.length} 条记录`);
|
||||
setSelectedRowKeys([]);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('批量删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 动态生成列
|
||||
const columns = [
|
||||
...fields.slice(0, 5).map((f) => ({
|
||||
@@ -336,6 +338,25 @@ export default function PluginCRUDPage({
|
||||
);
|
||||
case 'textarea':
|
||||
return <TextArea rows={3} />;
|
||||
case 'entity_select':
|
||||
return (
|
||||
<EntitySelect
|
||||
pluginId={pluginId}
|
||||
entity={field.ref_entity!}
|
||||
labelField={field.ref_label_field || 'name'}
|
||||
searchFields={field.ref_search_fields}
|
||||
value={formValues[field.name] as string | undefined}
|
||||
onChange={(v) => form.setFieldValue(field.name, v)}
|
||||
cascadeFrom={field.cascade_from}
|
||||
cascadeFilter={field.cascade_filter}
|
||||
cascadeValue={
|
||||
field.cascade_from
|
||||
? (formValues[field.cascade_from] as string | undefined)
|
||||
: undefined
|
||||
}
|
||||
placeholder={field.display_name}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Input />;
|
||||
}
|
||||
@@ -530,6 +551,34 @@ export default function PluginCRUDPage({
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedRowKeys.length > 0 && !compact && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: '8px 16px',
|
||||
background: 'var(--colorBgContainer, #fff)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<span>已选择 <strong>{selectedRowKeys.length}</strong> 项</span>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedRowKeys.length} 条记录?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={() => setSelectedRowKeys([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'table' || enableViews.length <= 1 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
@@ -537,6 +586,14 @@ export default function PluginCRUDPage({
|
||||
rowKey="_id"
|
||||
loading={loading}
|
||||
size={compact ? 'small' : undefined}
|
||||
rowSelection={
|
||||
compact
|
||||
? undefined
|
||||
: {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}
|
||||
}
|
||||
onChange={(_pagination, _filters, sorter) => {
|
||||
if (!Array.isArray(sorter) && sorter.field) {
|
||||
const newSortBy = String(sorter.field);
|
||||
@@ -588,7 +645,7 @@ export default function PluginCRUDPage({
|
||||
>
|
||||
{fields.map((field) => {
|
||||
// visible_when 条件显示
|
||||
const visible = shouldShowField(formValues, field.visible_when);
|
||||
const visible = evaluateVisibleWhen(field.visible_when, formValues);
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,340 +1,24 @@
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip } from 'antd';
|
||||
import { Row, Col, Empty, Select, Spin, theme } from 'antd';
|
||||
import { DashboardOutlined } from '@ant-design/icons';
|
||||
import { countPluginData, aggregatePluginData } from '../api/pluginData';
|
||||
import {
|
||||
TeamOutlined,
|
||||
PhoneOutlined,
|
||||
TagsOutlined,
|
||||
RiseOutlined,
|
||||
DashboardOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
|
||||
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
|
||||
|
||||
// ── 类型定义 ──
|
||||
|
||||
interface EntityStat {
|
||||
name: string;
|
||||
displayName: string;
|
||||
count: number;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
interface FieldBreakdown {
|
||||
fieldName: string;
|
||||
displayName: string;
|
||||
items: AggregateItem[];
|
||||
}
|
||||
|
||||
// ── 色板配置 ──
|
||||
|
||||
const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
|
||||
customer: {
|
||||
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||
tagColor: 'purple',
|
||||
},
|
||||
contact: {
|
||||
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PALETTE = {
|
||||
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
||||
iconBg: 'rgba(37, 99, 235, 0.12)',
|
||||
tagColor: 'blue',
|
||||
};
|
||||
|
||||
const TAG_COLORS = [
|
||||
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
|
||||
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
||||
];
|
||||
|
||||
// ── 图标映射 ──
|
||||
|
||||
const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
||||
customer: <TeamOutlined />,
|
||||
contact: <TeamOutlined />,
|
||||
communication: <PhoneOutlined />,
|
||||
customer_tag: <TagsOutlined />,
|
||||
customer_relationship: <RiseOutlined />,
|
||||
};
|
||||
|
||||
// ── 计数动画 Hook ──
|
||||
|
||||
function useCountUp(end: number, duration = 800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevEnd = useRef(end);
|
||||
|
||||
useEffect(() => {
|
||||
if (end === prevEnd.current && count > 0) return;
|
||||
prevEnd.current = end;
|
||||
|
||||
if (end === 0) {
|
||||
setCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
function tick(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(end * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── 子组件 ──
|
||||
|
||||
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||
const animatedValue = useCountUp(value);
|
||||
if (loading) return <Spin size="small" />;
|
||||
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
/** 顶部统计卡片 */
|
||||
function StatCard({
|
||||
stat,
|
||||
loading,
|
||||
delay,
|
||||
}: {
|
||||
stat: EntityStat;
|
||||
loading: boolean;
|
||||
delay: string;
|
||||
}) {
|
||||
return (
|
||||
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)} key={stat.name}>
|
||||
<div
|
||||
className={`erp-stat-card ${delay}`}
|
||||
style={
|
||||
{
|
||||
'--card-gradient': stat.gradient,
|
||||
'--card-icon-bg': stat.iconBg,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="erp-stat-card-bar" />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div className="erp-stat-card-title">{stat.displayName}</div>
|
||||
<div className="erp-stat-card-value">
|
||||
<StatValue value={stat.count} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="erp-stat-card-icon">{stat.icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 骨架屏卡片 */
|
||||
function SkeletonStatCard({ delay }: { delay: string }) {
|
||||
return (
|
||||
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)}>
|
||||
<div className={`erp-stat-card ${delay}`}>
|
||||
<div className="erp-stat-card-bar" style={{ opacity: 0.3 }} />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div style={{ width: 80, height: 14, marginBottom: 12 }}>
|
||||
<Skeleton.Input active size="small" style={{ width: 80, height: 14 }} />
|
||||
</div>
|
||||
<div style={{ width: 60, height: 32 }}>
|
||||
<Skeleton.Input active style={{ width: 60, height: 32 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 48, height: 48 }}>
|
||||
<Skeleton.Avatar active shape="square" size={48} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 字段分布卡片 */
|
||||
function BreakdownCard({
|
||||
breakdown,
|
||||
totalCount,
|
||||
palette,
|
||||
index,
|
||||
}: {
|
||||
breakdown: FieldBreakdown;
|
||||
totalCount: number;
|
||||
palette: { tagColor: string };
|
||||
index: number;
|
||||
}) {
|
||||
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
|
||||
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
|
||||
<div
|
||||
className={`erp-content-card erp-fade-in`}
|
||||
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
|
||||
>
|
||||
<div className="erp-section-header" style={{ marginBottom: 16 }}>
|
||||
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--erp-text-primary)',
|
||||
}}
|
||||
>
|
||||
{breakdown.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
color: 'var(--erp-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{breakdown.items.length} 项
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{breakdown.items.map((item, idx) => {
|
||||
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
|
||||
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
|
||||
const color = TAG_COLORS[idx % TAG_COLORS.length];
|
||||
|
||||
return (
|
||||
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
|
||||
<Tag
|
||||
color={color}
|
||||
style={{
|
||||
margin: 0,
|
||||
maxWidth: '60%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.key || '(空)'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--erp-text-primary)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{item.count}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 400,
|
||||
color: 'var(--erp-text-tertiary)',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{percent}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={barPercent}
|
||||
showInfo={false}
|
||||
strokeColor={color === 'blue' ? '#3B82F6'
|
||||
: color === 'green' ? '#10B981'
|
||||
: color === 'orange' ? '#F59E0B'
|
||||
: color === 'red' ? '#EF4444'
|
||||
: color === 'purple' ? '#8B5CF6'
|
||||
: color === 'cyan' ? '#06B6D4'
|
||||
: color === 'magenta' ? '#EC4899'
|
||||
: color === 'gold' ? '#EAB308'
|
||||
: color === 'lime' ? '#84CC16'
|
||||
: color === 'geekblue' ? '#6366F1'
|
||||
: color === 'volcano' ? '#F97316'
|
||||
: '#3B82F6'}
|
||||
trailColor="var(--erp-border-light)"
|
||||
size="small"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{breakdown.items.length === 0 && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无数据"
|
||||
style={{ padding: '12px 0' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 骨架屏分布卡片 */
|
||||
function SkeletonBreakdownCard({ index }: { index: number }) {
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<div
|
||||
className="erp-content-card erp-fade-in"
|
||||
style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}
|
||||
>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 延迟类名工具 ──
|
||||
|
||||
const DELAY_CLASSES = [
|
||||
'erp-fade-in erp-fade-in-delay-1',
|
||||
'erp-fade-in erp-fade-in-delay-2',
|
||||
'erp-fade-in erp-fade-in-delay-3',
|
||||
'erp-fade-in erp-fade-in-delay-4',
|
||||
'erp-fade-in erp-fade-in-delay-4',
|
||||
];
|
||||
|
||||
function getDelayClass(index: number): string {
|
||||
return DELAY_CLASSES[index % DELAY_CLASSES.length];
|
||||
}
|
||||
getPluginSchema,
|
||||
type PluginEntitySchema,
|
||||
type PluginSchemaResponse,
|
||||
type PluginPageSchema,
|
||||
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 {
|
||||
StatCard,
|
||||
SkeletonStatCard,
|
||||
BreakdownCard,
|
||||
SkeletonBreakdownCard,
|
||||
WidgetRenderer,
|
||||
} from './dashboard/DashboardWidgets';
|
||||
|
||||
// ── 主组件 ──
|
||||
|
||||
@@ -349,7 +33,10 @@ export function PluginDashboardPage() {
|
||||
const [entityStats, setEntityStats] = useState<EntityStat[]>([]);
|
||||
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Widget-based dashboard state
|
||||
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
|
||||
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
|
||||
const [widgetsLoading, setWidgetsLoading] = useState(false);
|
||||
const isDark =
|
||||
themeToken.colorBgContainer === '#111827' ||
|
||||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||
@@ -358,7 +45,6 @@ export function PluginDashboardPage() {
|
||||
useEffect(() => {
|
||||
if (!pluginId) return;
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadSchema() {
|
||||
setSchemaLoading(true);
|
||||
setError(null);
|
||||
@@ -370,6 +56,15 @@ export function PluginDashboardPage() {
|
||||
if (entityList.length > 0) {
|
||||
setSelectedEntity(entityList[0].name);
|
||||
}
|
||||
// 提取 dashboard widgets
|
||||
const pages = schema.ui?.pages || [];
|
||||
const dashboardPage = pages.find(
|
||||
(p): p is PluginPageSchema & { type: 'dashboard'; widgets?: DashboardWidget[] } =>
|
||||
p.type === 'dashboard',
|
||||
);
|
||||
if (dashboardPage?.widgets && dashboardPage.widgets.length > 0) {
|
||||
setWidgets(dashboardPage.widgets);
|
||||
}
|
||||
} catch {
|
||||
setError('Schema 加载失败,部分功能不可用');
|
||||
} finally {
|
||||
@@ -380,22 +75,18 @@ export function PluginDashboardPage() {
|
||||
loadSchema();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId]);
|
||||
|
||||
const currentEntity = useMemo(
|
||||
() => entities.find((e) => e.name === selectedEntity),
|
||||
[entities, selectedEntity],
|
||||
);
|
||||
|
||||
const filterableFields = useMemo(
|
||||
() => currentEntity?.fields.filter((f) => f.filterable) || [],
|
||||
[currentEntity],
|
||||
);
|
||||
|
||||
// 加载所有实体的计数
|
||||
useEffect(() => {
|
||||
if (!pluginId || entities.length === 0) return;
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadAllCounts() {
|
||||
const results: EntityStat[] = [];
|
||||
for (const entity of entities) {
|
||||
@@ -432,19 +123,55 @@ export function PluginDashboardPage() {
|
||||
loadAllCounts();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, entities]);
|
||||
// Widget 数据并行加载
|
||||
useEffect(() => {
|
||||
if (!pluginId || widgets.length === 0) return;
|
||||
const abortController = new AbortController();
|
||||
async function loadWidgetData() {
|
||||
setWidgetsLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
widgets.map(async (widget) => {
|
||||
try {
|
||||
if (widget.type === 'stat_card') {
|
||||
const count = await countPluginData(pluginId!, widget.entity);
|
||||
return { widget, data: [], count };
|
||||
}
|
||||
if (widget.dimension_field) {
|
||||
const data = await aggregatePluginData(
|
||||
pluginId!,
|
||||
widget.entity,
|
||||
widget.dimension_field,
|
||||
);
|
||||
return { widget, data };
|
||||
}
|
||||
// 没有 dimension_field 时仅返回计数
|
||||
const count = await countPluginData(pluginId!, widget.entity);
|
||||
return { widget, data: [], count };
|
||||
} catch {
|
||||
return { widget, data: [], count: 0 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!abortController.signal.aborted) {
|
||||
setWidgetData(results);
|
||||
}
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) setWidgetsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadWidgetData();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, widgets]);
|
||||
// 当前实体的聚合数据
|
||||
const loadData = useCallback(async () => {
|
||||
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const totalCount = entityStats.find((s) => s.name === selectedEntity)?.count ?? 0;
|
||||
const fieldResults: FieldBreakdown[] = [];
|
||||
|
||||
for (const field of filterableFields) {
|
||||
if (abortController.signal.aborted) return;
|
||||
try {
|
||||
@@ -460,39 +187,29 @@ export function PluginDashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setBreakdowns(fieldResults);
|
||||
}
|
||||
if (!abortController.signal.aborted) setBreakdowns(fieldResults);
|
||||
} catch {
|
||||
setError('统计数据加载失败');
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) setLoading(false);
|
||||
}
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, selectedEntity, filterableFields, entityStats]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = loadData();
|
||||
return () => {
|
||||
cleanup?.then((fn) => fn?.()).catch(() => {});
|
||||
};
|
||||
return () => { cleanup?.then((fn) => fn?.()).catch(() => {}); };
|
||||
}, [loadData]);
|
||||
|
||||
// 当前选中实体的总数
|
||||
const currentTotal = useMemo(
|
||||
() => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0,
|
||||
[entityStats, selectedEntity],
|
||||
);
|
||||
|
||||
// 当前实体的色板
|
||||
const currentPalette = useMemo(
|
||||
() => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
|
||||
[selectedEntity],
|
||||
);
|
||||
|
||||
// ── 渲染 ──
|
||||
|
||||
if (schemaLoading) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
@@ -572,6 +289,41 @@ export function PluginDashboardPage() {
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* Widget 图表区域 */}
|
||||
{widgets.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="erp-section-header">
|
||||
<DashboardOutlined
|
||||
className="erp-section-icon"
|
||||
style={{ color: '#4F46E5' }}
|
||||
/>
|
||||
<span className="erp-section-title">图表分析</span>
|
||||
</div>
|
||||
</div>
|
||||
{widgetsLoading && widgetData.length === 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{widgets.map((_, i) => (
|
||||
<SkeletonBreakdownCard key={i} index={i} />
|
||||
))}
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{widgetData.map((wd) => {
|
||||
const colSpan = wd.widget.type === 'stat_card' ? 6
|
||||
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
|
||||
: 12;
|
||||
return (
|
||||
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
|
||||
<WidgetRenderer widgetData={wd} isDark={isDark} />
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 分组统计区域 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="erp-section-header">
|
||||
@@ -607,7 +359,6 @@ export function PluginDashboardPage() {
|
||||
key={bd.fieldName}
|
||||
breakdown={bd}
|
||||
totalCount={currentTotal}
|
||||
palette={currentPalette}
|
||||
index={i}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type PluginSchemaResponse,
|
||||
} from '../api/plugins';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -300,7 +300,6 @@ function drawEdgeLabel(
|
||||
y: number,
|
||||
label: string,
|
||||
color: string,
|
||||
textColor: string,
|
||||
alpha: number,
|
||||
) {
|
||||
ctx.save();
|
||||
@@ -617,7 +616,7 @@ export function PluginGraphPage() {
|
||||
// Edge label
|
||||
if (edge.label && labelPos) {
|
||||
const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9;
|
||||
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, textColor, labelAlpha);
|
||||
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,7 +671,7 @@ export function PluginGraphPage() {
|
||||
if (hoverState.nodeId) {
|
||||
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
|
||||
if (hoveredNode) {
|
||||
const degree = degreeMap.get(hovered.nodeId) || 0;
|
||||
const degree = degreeMap.get(hoverState.nodeId) || 0;
|
||||
const tooltipText = `${hoveredNode.label} (${degree} 条关系)`;
|
||||
ctx.save();
|
||||
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
345
apps/web/src/pages/PluginKanbanPage.tsx
Normal file
345
apps/web/src/pages/PluginKanbanPage.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Spin, Typography, Tag, message } from 'antd';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCorners,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import { listPluginData, patchPluginData } from '../api/pluginData';
|
||||
import { getPluginSchema, type PluginPageSchema } from '../api/plugins';
|
||||
|
||||
// ── 内部看板渲染组件 ──
|
||||
|
||||
interface KanbanInnerProps {
|
||||
pluginId: string;
|
||||
entity: string;
|
||||
laneField: string;
|
||||
laneOrder: string[];
|
||||
cardTitleField: string;
|
||||
cardSubtitleField?: string;
|
||||
cardFields?: string[];
|
||||
enableDrag?: boolean;
|
||||
}
|
||||
|
||||
function KanbanInner({
|
||||
pluginId,
|
||||
entity,
|
||||
laneField,
|
||||
laneOrder,
|
||||
cardTitleField,
|
||||
cardSubtitleField,
|
||||
cardFields,
|
||||
enableDrag,
|
||||
}: KanbanInnerProps) {
|
||||
const [lanes, setLanes] = useState<Record<string, any[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allData: Record<string, any[]> = {};
|
||||
const results = await Promise.all(
|
||||
laneOrder.map(async (lane) => {
|
||||
const res = await listPluginData(pluginId, entity, 1, 100, {
|
||||
filter: { [laneField]: lane },
|
||||
});
|
||||
return { lane, data: res.data || [] };
|
||||
}),
|
||||
);
|
||||
for (const { lane, data } of results) {
|
||||
allData[lane] = data;
|
||||
}
|
||||
setLanes(allData);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [pluginId, entity]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
if (!enableDrag) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const recordId = active.id as string;
|
||||
const newLane = String(over.data.current?.lane || over.id);
|
||||
if (!newLane) return;
|
||||
|
||||
let currentLane = '';
|
||||
for (const [lane, items] of Object.entries(lanes)) {
|
||||
if (items.some((item) => item.id === recordId)) {
|
||||
currentLane = lane;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentLane === newLane) return;
|
||||
|
||||
// 乐观更新
|
||||
setLanes((prev) => {
|
||||
const next: Record<string, any[]> = {};
|
||||
for (const [lane, items] of Object.entries(prev)) {
|
||||
if (lane === currentLane) {
|
||||
next[lane] = items.filter((item) => item.id !== recordId);
|
||||
} else if (lane === newLane) {
|
||||
const moved = prev[currentLane]?.find((item) => item.id === recordId);
|
||||
next[lane] = moved ? [...items, moved] : [...items];
|
||||
} else {
|
||||
next[lane] = items;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
await patchPluginData(pluginId, entity, recordId, {
|
||||
data: { [laneField]: newLane },
|
||||
version: 0,
|
||||
});
|
||||
message.success('移动成功');
|
||||
} catch {
|
||||
message.error('移动失败');
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const activeCard = activeId
|
||||
? Object.values(lanes)
|
||||
.flat()
|
||||
.find((item) => item.id === activeId)
|
||||
: null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', padding: 16 }}>
|
||||
{laneOrder.map((lane) => {
|
||||
const items = lanes[lane] || [];
|
||||
return (
|
||||
<div
|
||||
key={lane}
|
||||
id={`lane-${lane}`}
|
||||
style={{
|
||||
minWidth: 280,
|
||||
flex: 1,
|
||||
background: 'var(--colorBgLayout, #f5f5f5)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>{lane}</Typography.Text>
|
||||
<Tag>{items.length}</Tag>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
size="small"
|
||||
style={{
|
||||
cursor: enableDrag ? 'grab' : 'default',
|
||||
opacity: activeId === item.id ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{item.data?.[cardTitleField] ?? '-'}
|
||||
</Typography.Text>
|
||||
{cardSubtitleField && item.data?.[cardSubtitleField] && (
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{item.data[cardSubtitleField]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{cardFields && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{cardFields.map(
|
||||
(f) =>
|
||||
item.data?.[f] ? (
|
||||
<Tag key={f}>{String(item.data[f])}</Tag>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeCard ? (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
cursor: 'grabbing',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
width: 260,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{activeCard.data?.[cardTitleField] ?? '-'}
|
||||
</Typography.Text>
|
||||
{cardSubtitleField && activeCard.data?.[cardSubtitleField] && (
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{activeCard.data[cardSubtitleField]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 路由入口:自加载 schema ──
|
||||
|
||||
/**
|
||||
* 路由入口组件
|
||||
* 路由: /plugins/:pluginId/kanban/:entityName
|
||||
* 自动加载 schema 并提取 kanban 页面配置
|
||||
*/
|
||||
export default function PluginKanbanPageRoute() {
|
||||
const { pluginId, entityName } = useParams<{
|
||||
pluginId: string;
|
||||
entityName: string;
|
||||
}>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pageConfig, setPageConfig] = useState<{
|
||||
entity: string;
|
||||
lane_field: string;
|
||||
lane_order?: string[];
|
||||
card_title_field: string;
|
||||
card_subtitle_field?: string;
|
||||
card_fields?: string[];
|
||||
enable_drag?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginId || !entityName) return;
|
||||
|
||||
async function loadSchema() {
|
||||
try {
|
||||
const schema = await getPluginSchema(pluginId!);
|
||||
const pages: PluginPageSchema[] = schema.ui?.pages || [];
|
||||
const kanbanPage = pages.find(
|
||||
(p): p is PluginPageSchema & { type: 'kanban' } =>
|
||||
p.type === 'kanban' && p.entity === entityName,
|
||||
);
|
||||
if (kanbanPage) {
|
||||
setPageConfig(kanbanPage);
|
||||
}
|
||||
} catch {
|
||||
message.warning('Schema 加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageConfig) {
|
||||
return <div style={{ padding: 24 }}>未找到看板页面配置</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<KanbanInner
|
||||
pluginId={pluginId!}
|
||||
entity={pageConfig.entity}
|
||||
laneField={pageConfig.lane_field}
|
||||
laneOrder={pageConfig.lane_order || []}
|
||||
cardTitleField={pageConfig.card_title_field}
|
||||
cardSubtitleField={pageConfig.card_subtitle_field}
|
||||
cardFields={pageConfig.card_fields}
|
||||
enableDrag={pageConfig.enable_drag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tabs/Detail 内嵌使用 ──
|
||||
|
||||
export interface PluginKanbanPageFromConfigProps {
|
||||
pluginId: string;
|
||||
page: {
|
||||
entity: string;
|
||||
lane_field: string;
|
||||
lane_order?: string[];
|
||||
card_title_field: string;
|
||||
card_subtitle_field?: string;
|
||||
card_fields?: string[];
|
||||
enable_drag?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function PluginKanbanPageFromConfig({
|
||||
pluginId,
|
||||
page,
|
||||
}: PluginKanbanPageFromConfigProps) {
|
||||
return (
|
||||
<KanbanInner
|
||||
pluginId={pluginId}
|
||||
entity={page.entity}
|
||||
laneField={page.lane_field}
|
||||
laneOrder={page.lane_order || []}
|
||||
cardTitleField={page.card_title_field}
|
||||
cardSubtitleField={page.card_subtitle_field}
|
||||
cardFields={page.card_fields}
|
||||
enableDrag={page.enable_drag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Tabs, Spin, message } from 'antd';
|
||||
import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
|
||||
import PluginCRUDPage from './PluginCRUDPage';
|
||||
import { PluginTreePage } from './PluginTreePage';
|
||||
import { PluginKanbanPageFromConfig } from './PluginKanbanPage';
|
||||
|
||||
/**
|
||||
* 插件 Tabs 页面 — 通过路由参数自加载 schema
|
||||
@@ -65,6 +66,14 @@ export function PluginTabsPage() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tab.type === 'kanban') {
|
||||
return (
|
||||
<PluginKanbanPageFromConfig
|
||||
pluginId={pluginId!}
|
||||
page={tab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div>不支持的页面类型: {tab.type}</div>;
|
||||
};
|
||||
|
||||
|
||||
298
apps/web/src/pages/dashboard/DashboardWidgets.tsx
Normal file
298
apps/web/src/pages/dashboard/DashboardWidgets.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd';
|
||||
import {
|
||||
InfoCircleOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
|
||||
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
|
||||
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
|
||||
|
||||
// ── 计数动画 Hook ──
|
||||
|
||||
function useCountUp(end: number, duration = 800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevEnd = useRef(end);
|
||||
useEffect(() => {
|
||||
if (end === prevEnd.current && count > 0) return;
|
||||
prevEnd.current = end;
|
||||
if (end === 0) { setCount(0); return; }
|
||||
const startTime = performance.now();
|
||||
function tick(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(end * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}, [end, duration]);
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── 共享工具 ──
|
||||
|
||||
function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
|
||||
return dimensionOrder
|
||||
? dimensionOrder
|
||||
.map((key) => data.find((d) => d.key === key))
|
||||
.filter(Boolean)
|
||||
.map((d) => ({ key: d!.key, count: d!.count }))
|
||||
: data.map((d) => ({ key: d.key, count: d.count }));
|
||||
}
|
||||
|
||||
const TAG_COLOR_MAP: Record<string, string> = {
|
||||
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
|
||||
purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308',
|
||||
lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316',
|
||||
};
|
||||
|
||||
function tagStrokeColor(color: string): string {
|
||||
return TAG_COLOR_MAP[color] || '#3B82F6';
|
||||
}
|
||||
|
||||
function WidgetCardShell({
|
||||
title,
|
||||
widgetType,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
widgetType: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP[widgetType]} {title}</span>}
|
||||
className="erp-fade-in"
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartEmpty() {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
|
||||
}
|
||||
|
||||
// ── 基础子组件 ──
|
||||
|
||||
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||
const animatedValue = useCountUp(value);
|
||||
if (loading) return <Spin size="small" />;
|
||||
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
/** 顶部统计卡片 */
|
||||
export function StatCard({ stat, loading, delay }: { stat: EntityStat; loading: boolean; delay: string }) {
|
||||
return (
|
||||
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)} key={stat.name}>
|
||||
<div
|
||||
className={`erp-stat-card ${delay}`}
|
||||
style={{ '--card-gradient': stat.gradient, '--card-icon-bg': stat.iconBg } as React.CSSProperties}
|
||||
>
|
||||
<div className="erp-stat-card-bar" />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div className="erp-stat-card-title">{stat.displayName}</div>
|
||||
<div className="erp-stat-card-value">
|
||||
<StatValue value={stat.count} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="erp-stat-card-icon">{stat.icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 骨架屏卡片 */
|
||||
export function SkeletonStatCard({ delay }: { delay: string }) {
|
||||
return (
|
||||
<Col xs={12} sm={8} lg={Math.max(4, 24 / 5)}>
|
||||
<div className={`erp-stat-card ${delay}`}>
|
||||
<div className="erp-stat-card-bar" style={{ opacity: 0.3 }} />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div style={{ width: 80, height: 14, marginBottom: 12 }}>
|
||||
<Skeleton.Input active size="small" style={{ width: 80, height: 14 }} />
|
||||
</div>
|
||||
<div style={{ width: 60, height: 32 }}>
|
||||
<Skeleton.Input active style={{ width: 60, height: 32 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 48, height: 48 }}>
|
||||
<Skeleton.Avatar active shape="square" size={48} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 字段分布卡片 */
|
||||
export function BreakdownCard({
|
||||
breakdown, totalCount, index,
|
||||
}: { breakdown: FieldBreakdown; totalCount: number; index: number }) {
|
||||
const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1);
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8} key={breakdown.fieldName}>
|
||||
<div className="erp-content-card erp-fade-in" style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}>
|
||||
<div className="erp-section-header" style={{ marginBottom: 16 }}>
|
||||
<InfoCircleOutlined className="erp-section-icon" style={{ fontSize: 14 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--erp-text-primary)' }}>
|
||||
{breakdown.displayName}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-tertiary)' }}>
|
||||
{breakdown.items.length} 项
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{breakdown.items.map((item, idx) => {
|
||||
const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0;
|
||||
const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0;
|
||||
const color = TAG_COLORS[idx % TAG_COLORS.length];
|
||||
return (
|
||||
<div key={`${breakdown.fieldName}-${item.key}-${idx}`}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Tooltip title={`${item.key}: ${item.count} (${percent}%)`}>
|
||||
<Tag color={color} style={{ margin: 0, maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.key || '(空)'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--erp-text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{item.count}
|
||||
<span style={{ fontSize: 11, fontWeight: 400, color: 'var(--erp-text-tertiary)', marginLeft: 4 }}>{percent}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={barPercent}
|
||||
showInfo={false}
|
||||
strokeColor={tagStrokeColor(color)}
|
||||
trailColor="var(--erp-border-light)"
|
||||
size="small"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{breakdown.items.length === 0 && (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" style={{ padding: '12px 0' }} />
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
/** 骨架屏分布卡片 */
|
||||
export function SkeletonBreakdownCard({ index }: { index: number }) {
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<div className="erp-content-card erp-fade-in" style={{ animationDelay: `${0.05 * index}s`, opacity: 0 }}>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Widget 图表子组件 ──
|
||||
|
||||
/** 统计卡片 widget */
|
||||
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||
const { widget, count } = widgetData;
|
||||
const animatedValue = useCountUp(count ?? 0);
|
||||
const color = widget.color || '#4F46E5';
|
||||
return (
|
||||
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10, background: `${color}18`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 20,
|
||||
}}>
|
||||
{WIDGET_ICON_MAP[widget.type] || <DashboardOutlined />}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{widget.title}</Typography.Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{animatedValue.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 柱状图 widget */
|
||||
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||
const { widget, data } = widgetData;
|
||||
const chartData = prepareChartData(data, widget.dimension_order);
|
||||
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||
return (
|
||||
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||
{chartData.length > 0 ? (
|
||||
<Column data={chartData} xField="key" yField="count" colorField="key"
|
||||
style={{ maxWidth: 40, maxWidthRatio: 0.6 }}
|
||||
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
|
||||
/>
|
||||
) : <ChartEmpty />}
|
||||
</WidgetCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 饼图 widget */
|
||||
function PieWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||
const { widget, data } = widgetData;
|
||||
const chartData = data.map((d) => ({ key: d.key, count: d.count }));
|
||||
return (
|
||||
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||
{chartData.length > 0 ? (
|
||||
<Pie data={chartData} angleField="count" colorField="key" radius={0.8} innerRadius={0.5}
|
||||
label={{ text: 'key', position: 'outside' as const }}
|
||||
legend={{ color: { position: 'bottom' as const } }}
|
||||
/>
|
||||
) : <ChartEmpty />}
|
||||
</WidgetCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 漏斗图 widget */
|
||||
function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
|
||||
const { widget, data } = widgetData;
|
||||
const chartData = prepareChartData(data, widget.dimension_order);
|
||||
return (
|
||||
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||
{chartData.length > 0 ? (
|
||||
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
|
||||
) : <ChartEmpty />}
|
||||
</WidgetCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 折线图 widget */
|
||||
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||
const { widget, data } = widgetData;
|
||||
const chartData = prepareChartData(data, widget.dimension_order);
|
||||
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' };
|
||||
return (
|
||||
<WidgetCardShell title={widget.title} widgetType={widget.type}>
|
||||
{chartData.length > 0 ? (
|
||||
<Line data={chartData} xField="key" yField="count" smooth
|
||||
axis={{ x: { label: { style: axisLabelStyle } }, y: { label: { style: axisLabelStyle } } }}
|
||||
/>
|
||||
) : <ChartEmpty />}
|
||||
</WidgetCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 渲染单个 widget */
|
||||
export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
|
||||
switch (widgetData.widget.type) {
|
||||
case 'stat_card': return <StatWidgetCard widgetData={widgetData} />;
|
||||
case 'bar_chart': return <BarWidgetCard widgetData={widgetData} isDark={isDark} />;
|
||||
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
|
||||
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
|
||||
case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
85
apps/web/src/pages/dashboard/dashboardConstants.ts
Normal file
85
apps/web/src/pages/dashboard/dashboardConstants.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
TeamOutlined,
|
||||
PhoneOutlined,
|
||||
TagsOutlined,
|
||||
RiseOutlined,
|
||||
DashboardOutlined,
|
||||
BarChartOutlined,
|
||||
PieChartOutlined,
|
||||
LineChartOutlined,
|
||||
FunnelPlotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
// ── 色板配置 ──
|
||||
|
||||
export const ENTITY_PALETTE: Record<string, { gradient: string; iconBg: string; tagColor: string }> = {
|
||||
customer: {
|
||||
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
|
||||
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||
tagColor: 'purple',
|
||||
},
|
||||
contact: {
|
||||
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||
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 = {
|
||||
gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)',
|
||||
iconBg: 'rgba(37, 99, 235, 0.12)',
|
||||
tagColor: 'blue',
|
||||
};
|
||||
|
||||
export const TAG_COLORS = [
|
||||
'blue', 'green', 'orange', 'red', 'purple', 'cyan',
|
||||
'magenta', 'gold', 'lime', 'geekblue', 'volcano',
|
||||
];
|
||||
|
||||
// ── 图标映射 ──
|
||||
|
||||
export const ENTITY_ICONS: Record<string, React.ReactNode> = {
|
||||
customer: <TeamOutlined />,
|
||||
contact: <TeamOutlined />,
|
||||
communication: <PhoneOutlined />,
|
||||
customer_tag: <TagsOutlined />,
|
||||
customer_relationship: <RiseOutlined />,
|
||||
};
|
||||
|
||||
export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
stat_card: <DashboardOutlined />,
|
||||
bar_chart: <BarChartOutlined />,
|
||||
pie_chart: <PieChartOutlined />,
|
||||
funnel_chart: <FunnelPlotOutlined />,
|
||||
line_chart: <LineChartOutlined />,
|
||||
};
|
||||
|
||||
// ── 延迟类名工具 ──
|
||||
|
||||
const DELAY_CLASSES = [
|
||||
'erp-fade-in erp-fade-in-delay-1',
|
||||
'erp-fade-in erp-fade-in-delay-2',
|
||||
'erp-fade-in erp-fade-in-delay-3',
|
||||
'erp-fade-in erp-fade-in-delay-4',
|
||||
'erp-fade-in erp-fade-in-delay-4',
|
||||
];
|
||||
|
||||
export function getDelayClass(index: number): string {
|
||||
return DELAY_CLASSES[index % DELAY_CLASSES.length];
|
||||
}
|
||||
25
apps/web/src/pages/dashboard/dashboardTypes.ts
Normal file
25
apps/web/src/pages/dashboard/dashboardTypes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type React from 'react';
|
||||
import type { AggregateItem, DashboardWidget } from '../../api/plugins';
|
||||
|
||||
// ── 类型定义 ──
|
||||
|
||||
export interface EntityStat {
|
||||
name: string;
|
||||
displayName: string;
|
||||
count: number;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
export interface FieldBreakdown {
|
||||
fieldName: string;
|
||||
displayName: string;
|
||||
items: AggregateItem[];
|
||||
}
|
||||
|
||||
export interface WidgetData {
|
||||
widget: DashboardWidget;
|
||||
data: AggregateItem[];
|
||||
count?: number;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export interface PluginMenuItem {
|
||||
label: string;
|
||||
pluginId: string;
|
||||
entity?: string;
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard' | 'kanban';
|
||||
}
|
||||
|
||||
export interface PluginMenuGroup {
|
||||
@@ -128,6 +128,15 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
pluginId: plugin.id,
|
||||
pageType: 'dashboard' as const,
|
||||
});
|
||||
} else if (page.type === 'kanban') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/kanban/${page.entity}`,
|
||||
icon: 'UnorderedListOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'kanban' as const,
|
||||
});
|
||||
}
|
||||
// detail 类型不生成菜单项
|
||||
}
|
||||
|
||||
138
apps/web/src/utils/exprEvaluator.ts
Normal file
138
apps/web/src/utils/exprEvaluator.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* visible_when 表达式解析与求值
|
||||
*
|
||||
* 支持语法:
|
||||
* field == 'value' 等值判断
|
||||
* field != 'value' 不等判断
|
||||
* expr1 AND expr2 逻辑与
|
||||
* expr1 OR expr2 逻辑或
|
||||
* NOT expr 逻辑非
|
||||
* (expr) 括号分组
|
||||
*/
|
||||
|
||||
interface ExprNode {
|
||||
type: 'eq' | 'and' | 'or' | 'not';
|
||||
field?: string;
|
||||
value?: string;
|
||||
left?: ExprNode;
|
||||
right?: ExprNode;
|
||||
operand?: ExprNode;
|
||||
}
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
if (input[i] === ' ') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '(' || input[i] === ')') {
|
||||
tokens.push(input[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === "'") {
|
||||
let j = i + 1;
|
||||
while (j < input.length && input[j] !== "'") j++;
|
||||
tokens.push(input.substring(i, j + 1));
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '=' && input[i + 1] === '=') {
|
||||
tokens.push('==');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (input[i] === '!' && input[i + 1] === '=') {
|
||||
tokens.push('!=');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (
|
||||
j < input.length &&
|
||||
!' ()\''.includes(input[j]) &&
|
||||
!(input[j] === '=' && input[j + 1] === '=') &&
|
||||
!(input[j] === '!' && input[j + 1] === '=')
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
tokens.push(input.substring(i, j));
|
||||
i = j;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseAtom(tokens: string[]): ExprNode | null {
|
||||
const token = tokens.shift();
|
||||
if (!token) return null;
|
||||
if (token === '(') {
|
||||
const expr = parseOr(tokens);
|
||||
if (tokens[0] === ')') tokens.shift();
|
||||
return expr;
|
||||
}
|
||||
if (token === 'NOT') {
|
||||
const operand = parseAtom(tokens);
|
||||
return { type: 'not', operand: operand || undefined };
|
||||
}
|
||||
const field = token;
|
||||
const op = tokens.shift();
|
||||
if (op !== '==' && op !== '!=') return null;
|
||||
const rawValue = tokens.shift() || '';
|
||||
const value = rawValue.replace(/^'(.*)'$/, '$1');
|
||||
return { type: 'eq', field, value };
|
||||
}
|
||||
|
||||
function parseAnd(tokens: string[]): ExprNode | null {
|
||||
let left = parseAtom(tokens);
|
||||
while (tokens[0] === 'AND') {
|
||||
tokens.shift();
|
||||
const right = parseAtom(tokens);
|
||||
if (left && right) {
|
||||
left = { type: 'and', left, right };
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseOr(tokens: string[]): ExprNode | null {
|
||||
let left = parseAnd(tokens);
|
||||
while (tokens[0] === 'OR') {
|
||||
tokens.shift();
|
||||
const right = parseAnd(tokens);
|
||||
if (left && right) {
|
||||
left = { type: 'or', left, right };
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
export function parseExpr(input: string): ExprNode | null {
|
||||
const tokens = tokenize(input);
|
||||
return parseOr(tokens);
|
||||
}
|
||||
|
||||
export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): boolean {
|
||||
switch (node.type) {
|
||||
case 'eq':
|
||||
return String(values[node.field!] ?? '') === node.value;
|
||||
case 'and':
|
||||
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
|
||||
case 'or':
|
||||
return evaluateExpr(node.left!, values) || evaluateExpr(node.right!, values);
|
||||
case 'not':
|
||||
return !evaluateExpr(node.operand!, values);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateVisibleWhen(
|
||||
expr: string | undefined,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!expr) return true;
|
||||
const ast = parseExpr(expr);
|
||||
return ast ? evaluateExpr(ast, values) : true;
|
||||
}
|
||||
@@ -47,11 +47,14 @@ pub async fn jwt_auth_middleware_fn(
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
// TODO: 待 user_positions 关联表建立后,从数据库查询用户所属部门 ID 列表
|
||||
// 当前阶段 department_ids 为空列表,行级数据权限默认为 all
|
||||
let ctx = TenantContext {
|
||||
tenant_id: claims.tid,
|
||||
user_id: claims.sub,
|
||||
roles: claims.roles,
|
||||
permissions: claims.permissions,
|
||||
department_ids: vec![],
|
||||
};
|
||||
|
||||
// Reconstruct the request with the TenantContext injected into extensions.
|
||||
|
||||
@@ -49,6 +49,7 @@ mod tests {
|
||||
user_id: Uuid::now_v7(),
|
||||
roles: roles.into_iter().map(String::from).collect(),
|
||||
permissions: permissions.into_iter().map(String::from).collect(),
|
||||
department_ids: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ mod tests {
|
||||
user_id: Uuid::now_v7(),
|
||||
roles: vec!["admin".to_string()],
|
||||
permissions: vec!["user.read".to_string()],
|
||||
department_ids: vec![],
|
||||
};
|
||||
assert_eq!(ctx.roles.len(), 1);
|
||||
assert_eq!(ctx.permissions.len(), 1);
|
||||
@@ -154,4 +155,6 @@ pub struct TenantContext {
|
||||
pub user_id: Uuid,
|
||||
pub roles: Vec<String>,
|
||||
pub permissions: Vec<String>,
|
||||
/// 用户所属部门 ID 列表(行级数据权限使用)
|
||||
pub department_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ min_platform_version = "0.1.0"
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
description = "查看客户列表和详情"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
|
||||
[[permissions]]
|
||||
code = "customer.manage"
|
||||
name = "管理客户"
|
||||
description = "创建、编辑、删除客户"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
|
||||
[[permissions]]
|
||||
code = "contact.list"
|
||||
@@ -51,6 +53,7 @@ name = "管理客户关系"
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
data_scope = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "code"
|
||||
@@ -163,6 +166,12 @@ display_name = "客户"
|
||||
display_name = "备注"
|
||||
ui_widget = "textarea"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "owner_id"
|
||||
field_type = "uuid"
|
||||
display_name = "负责人"
|
||||
scope_role = "owner"
|
||||
|
||||
[[schema.entities]]
|
||||
name = "contact"
|
||||
display_name = "联系人"
|
||||
@@ -172,6 +181,9 @@ display_name = "联系人"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ui_widget = "entity_select"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name", "code"]
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "name"
|
||||
@@ -229,6 +241,11 @@ display_name = "沟通记录"
|
||||
name = "contact_id"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
ui_widget = "entity_select"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name"]
|
||||
cascade_from = "customer_id"
|
||||
cascade_filter = "customer_id"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "type"
|
||||
@@ -423,3 +440,15 @@ node_label_field = "name"
|
||||
type = "dashboard"
|
||||
label = "统计概览"
|
||||
icon = "DashboardOutlined"
|
||||
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = "customer"
|
||||
label = "销售漏斗"
|
||||
icon = "swap"
|
||||
lane_field = "level"
|
||||
lane_order = ["potential", "normal", "vip", "svip"]
|
||||
card_title_field = "name"
|
||||
card_subtitle_field = "code"
|
||||
card_fields = ["region", "status"]
|
||||
enable_drag = true
|
||||
|
||||
@@ -23,3 +23,5 @@ utoipa = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = "0.22"
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
regex = "1"
|
||||
|
||||
@@ -17,13 +17,20 @@ pub struct CreatePluginDataReq {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 更新插件数据请求
|
||||
/// 更新插件数据请求(全量替换)
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdatePluginDataReq {
|
||||
pub data: serde_json::Value,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 部分更新请求(PATCH — 只合并提供的字段)
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct PatchPluginDataReq {
|
||||
pub data: serde_json::Value,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 插件数据列表查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct PluginDataListParams {
|
||||
@@ -65,3 +72,36 @@ pub struct CountQueryParams {
|
||||
/// JSON 格式过滤: {"field":"value"}
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// 批量操作请求
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct BatchActionReq {
|
||||
/// 操作类型: "batch_delete" 或 "batch_update"
|
||||
pub action: String,
|
||||
/// 记录 ID 列表(上限 100)
|
||||
pub ids: Vec<String>,
|
||||
/// batch_update 时的更新数据
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 时间序列查询参数
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)]
|
||||
pub struct TimeseriesParams {
|
||||
/// 时间字段名
|
||||
pub time_field: String,
|
||||
/// 时间粒度: "day" / "week" / "month"
|
||||
pub time_grain: String,
|
||||
/// 开始日期 (ISO)
|
||||
pub start: Option<String>,
|
||||
/// 结束日期 (ISO)
|
||||
pub end: Option<String>,
|
||||
}
|
||||
|
||||
/// 时间序列数据项
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct TimeseriesItem {
|
||||
/// 时间周期
|
||||
pub period: String,
|
||||
/// 计数
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
@@ -4,30 +4,16 @@ use uuid::Uuid;
|
||||
use erp_core::error::{AppError, AppResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::data_dto::PluginDataResp;
|
||||
use crate::dynamic_table::DynamicTableManager;
|
||||
use crate::data_dto::{BatchActionReq, PluginDataResp};
|
||||
use crate::dynamic_table::{sanitize_identifier, DynamicTableManager};
|
||||
use crate::entity::plugin;
|
||||
use crate::entity::plugin_entity;
|
||||
use crate::error::PluginError;
|
||||
use crate::manifest::PluginField;
|
||||
use crate::state::EntityInfo;
|
||||
|
||||
pub struct PluginDataService;
|
||||
|
||||
/// 插件实体信息(合并查询减少 DB 调用)
|
||||
struct EntityInfo {
|
||||
table_name: String,
|
||||
schema_json: serde_json::Value,
|
||||
}
|
||||
|
||||
impl EntityInfo {
|
||||
fn fields(&self) -> AppResult<Vec<PluginField>> {
|
||||
let entity_def: crate::manifest::PluginEntity =
|
||||
serde_json::from_value(self.schema_json.clone())
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
Ok(entity_def.fields)
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginDataService {
|
||||
/// 创建插件数据
|
||||
pub async fn create(
|
||||
@@ -42,6 +28,7 @@ impl PluginDataService {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.fields()?;
|
||||
validate_data(&data, &fields)?;
|
||||
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, true, None).await?;
|
||||
|
||||
let (sql, values) =
|
||||
DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data);
|
||||
@@ -73,7 +60,7 @@ impl PluginDataService {
|
||||
})
|
||||
}
|
||||
|
||||
/// 列表查询(支持过滤/搜索/排序)
|
||||
/// 列表查询(支持过滤/搜索/排序/Generated Column 路由)
|
||||
pub async fn list(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -85,8 +72,10 @@ impl PluginDataService {
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_order: Option<String>,
|
||||
cache: &moka::sync::Cache<String, EntityInfo>,
|
||||
) -> AppResult<(Vec<PluginDataResp>, u64)> {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let info =
|
||||
resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?;
|
||||
|
||||
// 获取 searchable 字段列表
|
||||
let entity_fields = info.fields()?;
|
||||
@@ -119,9 +108,9 @@ impl PluginDataService {
|
||||
.map(|r| r.count as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Query
|
||||
// Query — 使用 Generated Column 路由
|
||||
let offset = page.saturating_sub(1) * page_size;
|
||||
let (sql, values) = DynamicTableManager::build_filtered_query_sql(
|
||||
let (sql, values) = DynamicTableManager::build_filtered_query_sql_ex(
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
page_size,
|
||||
@@ -130,6 +119,7 @@ impl PluginDataService {
|
||||
search_tuple,
|
||||
sort_by,
|
||||
sort_order,
|
||||
&info.generated_fields,
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
|
||||
@@ -217,6 +207,14 @@ impl PluginDataService {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let fields = info.fields()?;
|
||||
validate_data(&data, &fields)?;
|
||||
validate_ref_entities(&data, &fields, entity_name, plugin_id, tenant_id, db, false, Some(id)).await?;
|
||||
|
||||
// 循环引用检测
|
||||
for field in &fields {
|
||||
if field.no_cycle == Some(true) && data.get(&field.name).is_some() {
|
||||
check_no_cycle(id, field, &data, &info.table_name, tenant_id, db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_update_sql(
|
||||
&info.table_name,
|
||||
@@ -254,6 +252,45 @@ impl PluginDataService {
|
||||
})
|
||||
}
|
||||
|
||||
/// 部分更新(PATCH)— 只合并提供的字段
|
||||
pub async fn partial_update(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
partial_data: serde_json::Value,
|
||||
expected_version: i32,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<PluginDataResp> {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_patch_sql(
|
||||
&info.table_name, id, tenant_id, operator_id, partial_data, expected_version,
|
||||
);
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct PatchResult {
|
||||
id: Uuid,
|
||||
data: serde_json::Value,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
let result = PatchResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres, sql, values,
|
||||
)).one(db).await?.ok_or_else(|| AppError::VersionMismatch)?;
|
||||
|
||||
Ok(PluginDataResp {
|
||||
id: result.id.to_string(),
|
||||
data: result.data,
|
||||
created_at: Some(result.created_at),
|
||||
updated_at: Some(result.updated_at),
|
||||
version: Some(result.version),
|
||||
})
|
||||
}
|
||||
|
||||
/// 删除(软删除)
|
||||
pub async fn delete(
|
||||
plugin_id: Uuid,
|
||||
@@ -264,6 +301,65 @@ impl PluginDataService {
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
// 解析 entity schema 获取 relations
|
||||
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 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,
|
||||
[id.to_string().into(), tenant_id.into()],
|
||||
)).one(db).await?;
|
||||
if has_ref.is_some() {
|
||||
return Err(AppError::Validation(format!(
|
||||
"存在关联的 {} 记录,无法删除",
|
||||
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,
|
||||
[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,
|
||||
[id.to_string().into(), tenant_id.into()],
|
||||
)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 软删除主记录
|
||||
let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id);
|
||||
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
@@ -276,6 +372,111 @@ impl PluginDataService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量操作 — batch_delete / batch_update
|
||||
pub async fn batch(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: BatchActionReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<u64> {
|
||||
if req.ids.is_empty() {
|
||||
return Err(AppError::Validation("ids 不能为空".to_string()));
|
||||
}
|
||||
if req.ids.len() > 100 {
|
||||
return Err(AppError::Validation("批量操作上限 100 条".to_string()));
|
||||
}
|
||||
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
let ids: Vec<Uuid> = req
|
||||
.ids
|
||||
.iter()
|
||||
.map(|s| Uuid::parse_str(s))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| AppError::Validation("ids 中包含无效的 UUID".to_string()))?;
|
||||
|
||||
let affected = match req.action.as_str() {
|
||||
"batch_delete" => {
|
||||
let placeholders: Vec<String> = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("${}", i + 2))
|
||||
.collect();
|
||||
let sql = format!(
|
||||
"UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() \
|
||||
WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL",
|
||||
info.table_name,
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let mut values = vec![tenant_id.into()];
|
||||
for id in &ids {
|
||||
values.push((*id).into());
|
||||
}
|
||||
let result = db
|
||||
.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await?;
|
||||
result.rows_affected()
|
||||
}
|
||||
"batch_update" => {
|
||||
let update_data = req.data.ok_or_else(|| {
|
||||
AppError::Validation("batch_update 需要 data 字段".to_string())
|
||||
})?;
|
||||
let mut set_expr = "data".to_string();
|
||||
if let Some(obj) = update_data.as_object() {
|
||||
for key in obj.keys() {
|
||||
let clean_key = sanitize_identifier(key);
|
||||
set_expr = format!(
|
||||
"jsonb_set({}, '{{{}}}', $2::jsonb->'{}', true)",
|
||||
set_expr, clean_key, clean_key
|
||||
);
|
||||
}
|
||||
}
|
||||
let placeholders: Vec<String> = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("${}", i + 3))
|
||||
.collect();
|
||||
let sql = format!(
|
||||
"UPDATE \"{}\" SET data = {}, updated_at = NOW(), updated_by = $1, version = version + 1 \
|
||||
WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL",
|
||||
info.table_name,
|
||||
set_expr,
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let mut values = vec![operator_id.into()];
|
||||
values.push(
|
||||
serde_json::to_string(&update_data)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
);
|
||||
for id in &ids {
|
||||
values.push((*id).into());
|
||||
}
|
||||
let result = db
|
||||
.execute(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.await?;
|
||||
result.rows_affected()
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Validation(format!(
|
||||
"不支持的批量操作: {}",
|
||||
req.action
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(affected)
|
||||
}
|
||||
|
||||
/// 统计记录数(支持过滤和搜索)
|
||||
pub async fn count(
|
||||
plugin_id: Uuid,
|
||||
@@ -367,6 +568,65 @@ impl PluginDataService {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 聚合查询(预留 Redis 缓存接口)
|
||||
pub async fn aggregate_cached(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
group_by_field: &str,
|
||||
filter: Option<serde_json::Value>,
|
||||
) -> AppResult<Vec<(String, i64)>> {
|
||||
// TODO: 未来版本添加 Redis 缓存层
|
||||
Self::aggregate(plugin_id, entity_name, tenant_id, db, group_by_field, filter).await
|
||||
}
|
||||
|
||||
/// 时间序列聚合 — 按时间字段截断为 day/week/month 统计计数
|
||||
pub async fn timeseries(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
time_field: &str,
|
||||
time_grain: &str,
|
||||
start: Option<String>,
|
||||
end: Option<String>,
|
||||
) -> AppResult<Vec<crate::data_dto::TimeseriesItem>> {
|
||||
let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?;
|
||||
|
||||
let (sql, values) = DynamicTableManager::build_timeseries_sql(
|
||||
&info.table_name,
|
||||
tenant_id,
|
||||
time_field,
|
||||
time_grain,
|
||||
start.as_deref(),
|
||||
end.as_deref(),
|
||||
)
|
||||
.map_err(|e| AppError::Validation(e))?;
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct TsRow {
|
||||
period: Option<String>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let rows = TsRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
values,
|
||||
))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| crate::data_dto::TimeseriesItem {
|
||||
period: r.period.unwrap_or_default(),
|
||||
count: r.count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 plugins 表解析 manifest metadata.id(如 "erp-crm")
|
||||
@@ -391,6 +651,7 @@ pub async fn resolve_manifest_id(
|
||||
}
|
||||
|
||||
/// 从 plugin_entities 表获取实体完整信息(带租户隔离)
|
||||
/// 注意:此函数不填充 generated_fields,仅用于非 list 场景
|
||||
async fn resolve_entity_info(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
@@ -411,19 +672,261 @@ async fn resolve_entity_info(
|
||||
Ok(EntityInfo {
|
||||
table_name: entity.table_name,
|
||||
schema_json: entity.schema_json,
|
||||
generated_fields: vec![], // 旧路径,不追踪 generated_fields
|
||||
})
|
||||
}
|
||||
|
||||
/// 校验数据:检查 required 字段
|
||||
/// 从缓存或数据库获取实体信息(带 generated_fields 解析)
|
||||
pub async fn resolve_entity_info_cached(
|
||||
plugin_id: Uuid,
|
||||
entity_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
cache: &moka::sync::Cache<String, EntityInfo>,
|
||||
) -> AppResult<EntityInfo> {
|
||||
let cache_key = format!("{}:{}:{}", plugin_id, entity_name, tenant_id);
|
||||
if let Some(info) = cache.get(&cache_key) {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
let entity = plugin_entity::Entity::find()
|
||||
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
|
||||
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
|
||||
.filter(plugin_entity::Column::EntityName.eq(entity_name))
|
||||
.filter(plugin_entity::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name))
|
||||
})?;
|
||||
|
||||
// 解析 generated_fields
|
||||
let entity_def: crate::manifest::PluginEntity =
|
||||
serde_json::from_value(entity.schema_json.clone())
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
let generated_fields: Vec<String> = entity_def
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|f| f.field_type.supports_generated_column())
|
||||
.filter(|f| {
|
||||
f.unique
|
||||
|| f.sortable == Some(true)
|
||||
|| f.filterable == Some(true)
|
||||
|| (f.required && (f.sortable == Some(true) || f.filterable == Some(true)))
|
||||
})
|
||||
.map(|f| sanitize_identifier(&f.name))
|
||||
.collect();
|
||||
|
||||
let info = EntityInfo {
|
||||
table_name: entity.table_name,
|
||||
schema_json: entity.schema_json,
|
||||
generated_fields,
|
||||
};
|
||||
|
||||
cache.insert(cache_key, info.clone());
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// 校验数据:检查 required 字段 + 正则校验
|
||||
fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> {
|
||||
let obj = data.as_object().ok_or_else(|| {
|
||||
AppError::Validation("data 必须是 JSON 对象".to_string())
|
||||
})?;
|
||||
|
||||
for field in fields {
|
||||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||
|
||||
// required 检查
|
||||
if field.required && !obj.contains_key(&field.name) {
|
||||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||
return Err(AppError::Validation(format!("字段 '{}' 不能为空", label)));
|
||||
}
|
||||
|
||||
// 正则校验
|
||||
if let Some(validation) = &field.validation {
|
||||
if let Some(pattern) = &validation.pattern {
|
||||
if let Some(val) = obj.get(&field.name) {
|
||||
let str_val = val.as_str().unwrap_or("");
|
||||
if !str_val.is_empty() {
|
||||
let re = regex::Regex::new(pattern)
|
||||
.map_err(|e| AppError::Internal(format!("正则表达式编译失败: {}", e)))?;
|
||||
if !re.is_match(str_val) {
|
||||
let default_msg = format!("字段 '{}' 格式不正确", label);
|
||||
let msg = validation.message.as_deref()
|
||||
.unwrap_or(&default_msg);
|
||||
return Err(AppError::Validation(msg.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在
|
||||
async fn validate_ref_entities(
|
||||
data: &serde_json::Value,
|
||||
fields: &[PluginField],
|
||||
current_entity: &str,
|
||||
plugin_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
is_create: bool,
|
||||
record_id: Option<Uuid>,
|
||||
) -> AppResult<()> {
|
||||
let obj = data.as_object().ok_or_else(|| {
|
||||
AppError::Validation("data 必须是 JSON 对象".to_string())
|
||||
})?;
|
||||
|
||||
for field in fields {
|
||||
let Some(ref_entity_name) = &field.ref_entity else { continue };
|
||||
let Some(val) = obj.get(&field.name) else { continue };
|
||||
let str_val = val.as_str().unwrap_or("").trim().to_string();
|
||||
|
||||
if str_val.is_empty() && !field.required { continue; }
|
||||
if str_val.is_empty() { continue; }
|
||||
|
||||
let ref_id = Uuid::parse_str(&str_val).map_err(|_| {
|
||||
AppError::Validation(format!(
|
||||
"字段 '{}' 的值 '{}' 不是有效的 UUID",
|
||||
field.display_name.as_deref().unwrap_or(&field.name),
|
||||
str_val
|
||||
))
|
||||
})?;
|
||||
|
||||
// 自引用 + create:跳过(记录尚未存在)
|
||||
if ref_entity_name == current_entity && is_create {
|
||||
continue;
|
||||
}
|
||||
// 自引用 + update:检查是否引用自身
|
||||
if ref_entity_name == current_entity && !is_create {
|
||||
if let Some(rid) = record_id {
|
||||
if ref_id == rid { continue; }
|
||||
}
|
||||
}
|
||||
|
||||
// 查询被引用记录是否存在
|
||||
let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?;
|
||||
let ref_table = DynamicTableManager::table_name(&manifest_id, ref_entity_name);
|
||||
|
||||
let check_sql = format!(
|
||||
"SELECT 1 as check_result FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1",
|
||||
ref_table
|
||||
);
|
||||
#[derive(FromQueryResult)]
|
||||
struct ExistsCheck { check_result: Option<i32> }
|
||||
let result = ExistsCheck::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
check_sql,
|
||||
[ref_id.into(), tenant_id.into()],
|
||||
)).one(db).await?;
|
||||
|
||||
if result.is_none() {
|
||||
return Err(AppError::Validation(format!(
|
||||
"引用的 {} 记录不存在(ID: {})",
|
||||
ref_entity_name, ref_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 循环引用检测 — 用于 no_cycle 字段
|
||||
async fn check_no_cycle(
|
||||
record_id: Uuid,
|
||||
field: &PluginField,
|
||||
data: &serde_json::Value,
|
||||
table_name: &str,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
let Some(val) = data.get(&field.name) else { return Ok(()) };
|
||||
let new_parent = val.as_str().unwrap_or("").trim().to_string();
|
||||
if new_parent.is_empty() { return Ok(()); }
|
||||
|
||||
let new_parent_id = Uuid::parse_str(&new_parent).map_err(|_| {
|
||||
AppError::Validation("parent_id 不是有效的 UUID".to_string())
|
||||
})?;
|
||||
|
||||
let field_name = sanitize_identifier(&field.name);
|
||||
let mut visited = vec![record_id];
|
||||
let mut current_id = new_parent_id;
|
||||
|
||||
for _ in 0..100 {
|
||||
if visited.contains(¤t_id) {
|
||||
let label = field.display_name.as_deref().unwrap_or(&field.name);
|
||||
return Err(AppError::Validation(format!(
|
||||
"字段 '{}' 形成循环引用", label
|
||||
)));
|
||||
}
|
||||
visited.push(current_id);
|
||||
|
||||
let query_sql = format!(
|
||||
"SELECT data->>'{}' as parent FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||
field_name, table_name
|
||||
);
|
||||
#[derive(FromQueryResult)]
|
||||
struct ParentRow { parent: Option<String> }
|
||||
let row = ParentRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
query_sql,
|
||||
[current_id.into(), tenant_id.into()],
|
||||
)).one(db).await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let parent = r.parent.unwrap_or_default().trim().to_string();
|
||||
if parent.is_empty() { break; }
|
||||
current_id = Uuid::parse_str(&parent).map_err(|_| {
|
||||
AppError::Internal("parent_id 不是有效的 UUID".to_string())
|
||||
})?;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod validate_tests {
|
||||
use super::*;
|
||||
use crate::manifest::{FieldValidation, PluginField, PluginFieldType};
|
||||
|
||||
fn make_field(name: &str, pattern: Option<&str>, message: Option<&str>) -> PluginField {
|
||||
PluginField {
|
||||
name: name.to_string(),
|
||||
field_type: PluginFieldType::String,
|
||||
required: false,
|
||||
validation: pattern.map(|p| FieldValidation {
|
||||
pattern: Some(p.to_string()),
|
||||
message: message.map(|m| m.to_string()),
|
||||
}),
|
||||
..PluginField::default_for_field()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_phone_pattern_rejects_invalid() {
|
||||
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
|
||||
let data = serde_json::json!({"phone": "1234"});
|
||||
let result = validate_data(&data, &fields);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_phone_pattern_accepts_valid() {
|
||||
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), Some("手机号格式不正确"))];
|
||||
let data = serde_json::json!({"phone": "13812345678"});
|
||||
let result = validate_data(&data, &fields);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_empty_optional_field_skips_pattern() {
|
||||
let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), None)];
|
||||
let data = serde_json::json!({"phone": ""});
|
||||
let result = validate_data(&data, &fields);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,44 @@ impl DynamicTableManager {
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 PATCH SQL — 只更新 data 中提供的字段,未提供的保持不变
|
||||
/// 使用 jsonb_set 逐层合并,实现部分更新
|
||||
pub fn build_patch_sql(
|
||||
table_name: &str,
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
partial_data: serde_json::Value,
|
||||
version: i32,
|
||||
) -> (String, Vec<Value>) {
|
||||
let mut set_expr = "data".to_string();
|
||||
if let Some(obj) = partial_data.as_object() {
|
||||
for key in obj.keys() {
|
||||
let clean_key = sanitize_identifier(key);
|
||||
set_expr = format!(
|
||||
"jsonb_set({}, '{{{}}}', $1::jsonb->'{}', true)",
|
||||
set_expr, clean_key, clean_key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE \"{}\" \
|
||||
SET data = {}, updated_at = NOW(), updated_by = $2, version = version + 1 \
|
||||
WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \
|
||||
RETURNING id, data, created_at, updated_at, version",
|
||||
table_name, set_expr
|
||||
);
|
||||
let values = vec![
|
||||
serde_json::to_string(&partial_data).unwrap_or_default().into(),
|
||||
user_id.into(),
|
||||
id.into(),
|
||||
tenant_id.into(),
|
||||
version.into(),
|
||||
];
|
||||
(sql, values)
|
||||
}
|
||||
|
||||
/// 构建 DELETE SQL(软删除)
|
||||
pub fn build_delete_sql(
|
||||
table_name: &str,
|
||||
@@ -600,6 +638,68 @@ impl DynamicTableManager {
|
||||
Ok((sql, values))
|
||||
}
|
||||
|
||||
/// 构建数据范围 SQL 条件 — 用于行级数据权限过滤
|
||||
///
|
||||
/// 根据权限范围级别 (scope_level) 生成对应的 WHERE 子句和参数:
|
||||
/// - "all": 无额外条件(空字符串)
|
||||
/// - "self": 只能看自己创建/拥有的数据
|
||||
/// - "department" / "department_tree": 能看部门成员的数据
|
||||
///
|
||||
/// 返回 (sql_fragment, params),sql_fragment 可直接拼接到 WHERE 子句中
|
||||
pub fn build_data_scope_condition_with_params(
|
||||
scope_level: &str,
|
||||
current_user_id: &Uuid,
|
||||
owner_field: &str,
|
||||
dept_member_ids: &[Uuid],
|
||||
start_param_idx: usize,
|
||||
generated_fields: &[String],
|
||||
) -> (String, Vec<Value>) {
|
||||
let ref_fn = Self::field_reference_fn(generated_fields);
|
||||
let owner_ref = ref_fn(owner_field);
|
||||
match scope_level {
|
||||
"self" => (
|
||||
format!(
|
||||
"({} = ${} OR \"created_by\" = ${})",
|
||||
owner_ref, start_param_idx, start_param_idx
|
||||
),
|
||||
vec![
|
||||
current_user_id.to_string().into(),
|
||||
(*current_user_id).into(),
|
||||
],
|
||||
),
|
||||
"department" | "department_tree" => {
|
||||
if dept_member_ids.is_empty() {
|
||||
// 部门成员为空时退化为 self 范围
|
||||
(
|
||||
format!(
|
||||
"({} = ${} OR \"created_by\" = ${})",
|
||||
owner_ref, start_param_idx, start_param_idx
|
||||
),
|
||||
vec![
|
||||
current_user_id.to_string().into(),
|
||||
(*current_user_id).into(),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
let placeholders: Vec<String> = dept_member_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("${}", start_param_idx + i))
|
||||
.collect();
|
||||
let values: Vec<Value> = dept_member_ids
|
||||
.iter()
|
||||
.map(|id| id.to_string().into())
|
||||
.collect();
|
||||
(
|
||||
format!("{} IN ({})", owner_ref, placeholders.join(", ")),
|
||||
values,
|
||||
)
|
||||
}
|
||||
}
|
||||
"all" | _ => (String::new(), vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
/// 编码游标
|
||||
pub fn encode_cursor(values: &[String], id: &Uuid) -> String {
|
||||
let obj = serde_json::json!({
|
||||
@@ -683,6 +783,61 @@ impl DynamicTableManager {
|
||||
|
||||
Ok((sql, values))
|
||||
}
|
||||
|
||||
/// 构建时间序列查询 SQL
|
||||
pub fn build_timeseries_sql(
|
||||
table_name: &str,
|
||||
tenant_id: Uuid,
|
||||
time_field: &str,
|
||||
time_grain: &str,
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<(String, Vec<Value>), String> {
|
||||
let clean_field = sanitize_identifier(time_field);
|
||||
let grain = match time_grain {
|
||||
"day" | "week" | "month" => time_grain,
|
||||
_ => return Err(format!("不支持的 time_grain: {}", time_grain)),
|
||||
};
|
||||
|
||||
let mut conditions = vec![
|
||||
"\"tenant_id\" = $1".to_string(),
|
||||
"\"deleted_at\" IS NULL".to_string(),
|
||||
];
|
||||
let mut values: Vec<Value> = vec![tenant_id.into()];
|
||||
let mut param_idx = 2;
|
||||
|
||||
if let Some(s) = start {
|
||||
conditions.push(format!(
|
||||
"(data->>'{}')::timestamp >= ${}",
|
||||
clean_field, param_idx
|
||||
));
|
||||
values.push(Value::String(Some(Box::new(s.to_string()))));
|
||||
param_idx += 1;
|
||||
}
|
||||
if let Some(e) = end {
|
||||
conditions.push(format!(
|
||||
"(data->>'{}')::timestamp < ${}",
|
||||
clean_field, param_idx
|
||||
));
|
||||
values.push(Value::String(Some(Box::new(e.to_string()))));
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"SELECT to_char(date_trunc('{}', (data->>'{}')::timestamp), 'YYYY-MM-DD') as period, \
|
||||
COUNT(*) as count \
|
||||
FROM \"{}\" WHERE {} \
|
||||
GROUP BY date_trunc('{}', (data->>'{}')::timestamp) \
|
||||
ORDER BY period",
|
||||
grain,
|
||||
clean_field,
|
||||
table_name,
|
||||
conditions.join(" AND "),
|
||||
grain,
|
||||
clean_field,
|
||||
);
|
||||
|
||||
Ok((sql, values))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -809,6 +964,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== build_patch_sql 测试 =====
|
||||
|
||||
#[test]
|
||||
fn test_build_patch_sql_merges_fields() {
|
||||
let (sql, values) = DynamicTableManager::build_patch_sql(
|
||||
"plugin_test_customer",
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000099").unwrap(),
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000050").unwrap(),
|
||||
serde_json::json!({"level": "vip", "status": "active"}),
|
||||
3,
|
||||
);
|
||||
assert!(sql.contains("jsonb_set"), "PATCH 应使用 jsonb_set 合并");
|
||||
assert!(sql.contains("version = version + 1"), "PATCH 应更新版本号");
|
||||
assert!(sql.contains("WHERE id = $3"), "应有 id 条件");
|
||||
assert!(sql.contains("version = $5"), "应有乐观锁");
|
||||
assert_eq!(values.len(), 5, "应有 5 个参数");
|
||||
}
|
||||
|
||||
// ===== build_filtered_count_sql 测试 =====
|
||||
|
||||
#[test]
|
||||
@@ -956,6 +1130,8 @@ mod tests {
|
||||
},
|
||||
],
|
||||
indexes: vec![],
|
||||
relations: vec![],
|
||||
data_scope: None,
|
||||
};
|
||||
|
||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||
@@ -996,6 +1172,8 @@ mod tests {
|
||||
..PluginField::default_for_field()
|
||||
}],
|
||||
indexes: vec![],
|
||||
relations: vec![],
|
||||
data_scope: None,
|
||||
};
|
||||
|
||||
let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity);
|
||||
@@ -1122,4 +1300,197 @@ mod tests {
|
||||
"应有 tenant_id + cursor_val + cursor_id + limit"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== build_data_scope_condition_with_params 测试 =====
|
||||
|
||||
#[test]
|
||||
fn test_build_data_scope_condition_self() {
|
||||
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
|
||||
"self",
|
||||
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"owner_id",
|
||||
&[],
|
||||
1,
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
sql.contains("\"data\"->>'owner_id'"),
|
||||
"self 应包含 owner_id 条件, got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("\"created_by\""),
|
||||
"self 应包含 created_by 条件, got: {}",
|
||||
sql
|
||||
);
|
||||
assert_eq!(values.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_data_scope_condition_department() {
|
||||
let dept_members = vec![
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
|
||||
];
|
||||
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
|
||||
"department",
|
||||
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"owner_id",
|
||||
&dept_members,
|
||||
2,
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
sql.contains("IN"),
|
||||
"department 应使用 IN 条件, got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("$2"),
|
||||
"参数索引应从 2 开始, got: {}",
|
||||
sql
|
||||
);
|
||||
assert!(
|
||||
sql.contains("$3"),
|
||||
"第二个参数索引应为 3, got: {}",
|
||||
sql
|
||||
);
|
||||
assert_eq!(values.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_data_scope_condition_department_empty_degrades_to_self() {
|
||||
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
|
||||
"department",
|
||||
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"owner_id",
|
||||
&[],
|
||||
1,
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
sql.contains("\"created_by\""),
|
||||
"空部门应退化为 self 范围, got: {}",
|
||||
sql
|
||||
);
|
||||
assert_eq!(values.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_data_scope_condition_all() {
|
||||
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
|
||||
"all",
|
||||
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"owner_id",
|
||||
&[],
|
||||
1,
|
||||
&[],
|
||||
);
|
||||
assert!(sql.is_empty(), "all 应返回空条件");
|
||||
assert!(values.is_empty(), "all 应返回空参数");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_data_scope_condition_department_tree() {
|
||||
let dept_members = vec![
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
|
||||
];
|
||||
let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params(
|
||||
"department_tree",
|
||||
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"owner_id",
|
||||
&dept_members,
|
||||
5,
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
sql.contains("IN"),
|
||||
"department_tree 应使用 IN 条件, got: {}",
|
||||
sql
|
||||
);
|
||||
assert_eq!(values.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_data_scope_condition_with_generated_column() {
|
||||
let generated = vec!["owner_id".to_string()];
|
||||
let (sql, _) = DynamicTableManager::build_data_scope_condition_with_params(
|
||||
"self",
|
||||
&Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"owner_id",
|
||||
&[],
|
||||
1,
|
||||
&generated,
|
||||
);
|
||||
assert!(
|
||||
sql.contains("\"_f_owner_id\""),
|
||||
"generated column 应使用 _f_ 前缀, got: {}",
|
||||
sql
|
||||
);
|
||||
}
|
||||
|
||||
// ===== build_timeseries_sql 测试 =====
|
||||
|
||||
#[test]
|
||||
fn test_build_timeseries_sql_day_grain() {
|
||||
let (sql, values) = DynamicTableManager::build_timeseries_sql(
|
||||
"plugin_test",
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"occurred_at",
|
||||
"day",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sql.contains("date_trunc('day'"), "应有 day 粒度");
|
||||
assert!(sql.contains("GROUP BY"), "应有 GROUP BY");
|
||||
assert!(sql.contains("ORDER BY period"), "应按 period 排序");
|
||||
assert_eq!(values.len(), 1, "仅 tenant_id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_timeseries_sql_month_grain() {
|
||||
let (sql, _) = DynamicTableManager::build_timeseries_sql(
|
||||
"plugin_test",
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"created_date",
|
||||
"month",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sql.contains("date_trunc('month'"), "应有 month 粒度");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_timeseries_sql_with_date_range() {
|
||||
let (sql, values) = DynamicTableManager::build_timeseries_sql(
|
||||
"plugin_test",
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"occurred_at",
|
||||
"week",
|
||||
Some("2026-01-01"),
|
||||
Some("2026-04-01"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sql.contains("date_trunc('week'"), "应有 week 粒度");
|
||||
assert!(sql.contains(">="), "应有 start 条件");
|
||||
assert!(sql.contains("<"), "应有 end 条件");
|
||||
assert_eq!(values.len(), 3, "tenant_id + start + end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_timeseries_sql_invalid_grain() {
|
||||
let result = DynamicTableManager::build_timeseries_sql(
|
||||
"plugin_test",
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"occurred_at",
|
||||
"hour",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err(), "不支持的 grain 应报错");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,68 @@ use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::data_dto::{
|
||||
AggregateItem, AggregateQueryParams, CountQueryParams, CreatePluginDataReq,
|
||||
PluginDataListParams, PluginDataResp, UpdatePluginDataReq,
|
||||
AggregateItem, AggregateQueryParams, BatchActionReq, CountQueryParams, CreatePluginDataReq,
|
||||
PatchPluginDataReq, PluginDataListParams, PluginDataResp, TimeseriesItem, TimeseriesParams,
|
||||
UpdatePluginDataReq,
|
||||
};
|
||||
use crate::data_service::{PluginDataService, resolve_manifest_id};
|
||||
use crate::state::PluginState;
|
||||
|
||||
/// 获取当前用户对指定权限的 data_scope 等级
|
||||
///
|
||||
/// 查询 user_roles -> role_permissions -> permissions 链路,
|
||||
/// 返回匹配权限的 data_scope 设置,默认 "all"。
|
||||
async fn get_data_scope(
|
||||
ctx: &TenantContext,
|
||||
permission_code: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> Result<String, AppError> {
|
||||
use sea_orm::{FromQueryResult, Statement};
|
||||
|
||||
#[derive(FromQueryResult)]
|
||||
struct ScopeResult {
|
||||
data_scope: Option<String>,
|
||||
}
|
||||
|
||||
let result = ScopeResult::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"SELECT rp.data_scope
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = $1 AND ur.tenant_id = $2 AND p.code = $3
|
||||
LIMIT 1"#,
|
||||
[
|
||||
ctx.user_id.into(),
|
||||
ctx.tenant_id.into(),
|
||||
permission_code.into(),
|
||||
],
|
||||
))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(result
|
||||
.and_then(|r| r.data_scope)
|
||||
.unwrap_or_else(|| "all".to_string()))
|
||||
}
|
||||
|
||||
/// 获取部门成员 ID 列表
|
||||
///
|
||||
/// 当前返回 TenantContext 中的 department_ids。
|
||||
/// 未来实现递归查询部门树时将支持 include_sub_depts 参数。
|
||||
async fn get_dept_members(
|
||||
ctx: &TenantContext,
|
||||
_include_sub_depts: bool,
|
||||
) -> Vec<Uuid> {
|
||||
// 当前 department_ids 为空时返回空列表
|
||||
// 未来实现递归查询部门树
|
||||
if ctx.department_ids.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
ctx.department_ids.clone()
|
||||
}
|
||||
|
||||
/// 计算插件数据操作所需的权限码
|
||||
/// 格式:{manifest_id}.{entity}.{action},如 erp-crm.customer.list
|
||||
fn compute_permission_code(manifest_id: &str, entity_name: &str, action: &str) -> String {
|
||||
@@ -47,9 +103,14 @@ where
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
// TODO(data_scope): 此处注入行级数据权限过滤
|
||||
// 1. 解析 entity 定义检查 data_scope == Some(true)
|
||||
// 2. 调用 get_data_scope(&ctx, &fine_perm, &state.db) 获取当前用户的 scope 等级
|
||||
// 3. 若 scope != "all",调用 get_dept_members 获取部门成员列表
|
||||
// 4. 将 scope 条件合并到 filter 中传给 PluginDataService::list
|
||||
// 参考: crates/erp-plugin/src/dynamic_table.rs build_data_scope_condition()
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
@@ -71,6 +132,7 @@ where
|
||||
params.search,
|
||||
params.sort_by,
|
||||
params.sort_order,
|
||||
&state.entity_cache,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -106,9 +168,7 @@ where
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "create");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let result = PluginDataService::create(
|
||||
plugin_id,
|
||||
@@ -145,9 +205,7 @@ where
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "get");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let result =
|
||||
PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?;
|
||||
@@ -178,9 +236,7 @@ where
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "update");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let result = PluginDataService::update(
|
||||
plugin_id,
|
||||
@@ -198,6 +254,39 @@ where
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
request_body = PatchPluginDataReq,
|
||||
responses(
|
||||
(status = 200, description = "部分更新成功", body = ApiResponse<PluginDataResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// PATCH /api/v1/plugins/{plugin_id}/{entity}/{id} — 部分更新(jsonb_set 合并字段)
|
||||
pub async fn patch_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>,
|
||||
Json(req): Json<PatchPluginDataReq>,
|
||||
) -> Result<Json<ApiResponse<PluginDataResp>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "update");
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let result = PluginDataService::partial_update(
|
||||
plugin_id, &entity, id, ctx.tenant_id, ctx.user_id,
|
||||
req.data, req.version, &state.db,
|
||||
).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/{id}",
|
||||
@@ -219,9 +308,7 @@ where
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "delete");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.admin")?;
|
||||
}
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
PluginDataService::delete(
|
||||
plugin_id,
|
||||
@@ -236,6 +323,49 @@ where
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/batch",
|
||||
request_body = BatchActionReq,
|
||||
responses(
|
||||
(status = 200, description = "批量操作成功", body = ApiResponse<u64>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// POST /api/v1/plugins/{plugin_id}/{entity}/batch — 批量操作 (batch_delete / batch_update)
|
||||
pub async fn batch_plugin_data<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Json(req): Json<BatchActionReq>,
|
||||
) -> Result<Json<ApiResponse<u64>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let action_perm = match req.action.as_str() {
|
||||
"batch_delete" => "delete",
|
||||
"batch_update" => "update",
|
||||
_ => "update",
|
||||
};
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, action_perm);
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let affected = PluginDataService::batch(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
req,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(affected)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/count",
|
||||
@@ -259,9 +389,7 @@ where
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
// 解析 filter JSON
|
||||
let filter: Option<serde_json::Value> = params
|
||||
@@ -305,9 +433,7 @@ where
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
if require_permission(&ctx, &fine_perm).is_err() {
|
||||
require_permission(&ctx, "plugin.list")?;
|
||||
}
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
// 解析 filter JSON
|
||||
let filter: Option<serde_json::Value> = params
|
||||
@@ -332,3 +458,43 @@ where
|
||||
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/plugins/{plugin_id}/{entity}/timeseries",
|
||||
params(TimeseriesParams),
|
||||
responses(
|
||||
(status = 200, description = "时间序列数据", body = ApiResponse<Vec<TimeseriesItem>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "插件数据"
|
||||
)]
|
||||
/// GET /api/v1/plugins/{plugin_id}/{entity}/timeseries — 时间序列聚合
|
||||
pub async fn get_plugin_timeseries<S>(
|
||||
State(state): State<PluginState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((plugin_id, entity)): Path<(Uuid, String)>,
|
||||
Query(params): Query<TimeseriesParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<TimeseriesItem>>>, AppError>
|
||||
where
|
||||
PluginState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?;
|
||||
let fine_perm = compute_permission_code(&manifest_id, &entity, "list");
|
||||
require_permission(&ctx, &fine_perm)?;
|
||||
|
||||
let result = PluginDataService::timeseries(
|
||||
plugin_id,
|
||||
&entity,
|
||||
ctx.tenant_id,
|
||||
&state.db,
|
||||
¶ms.time_field,
|
||||
¶ms.time_grain,
|
||||
params.start,
|
||||
params.end,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
@@ -43,6 +43,17 @@ pub struct PluginEntity {
|
||||
pub fields: Vec<PluginField>,
|
||||
#[serde(default)]
|
||||
pub indexes: Vec<PluginIndex>,
|
||||
#[serde(default)]
|
||||
pub relations: Vec<PluginRelation>,
|
||||
#[serde(default)]
|
||||
pub data_scope: Option<bool>, // 是否启用行级数据权限
|
||||
}
|
||||
|
||||
/// 字段校验规则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldValidation {
|
||||
pub pattern: Option<String>, // 正则表达式
|
||||
pub message: Option<String>, // 校验失败提示
|
||||
}
|
||||
|
||||
/// 插件字段定义
|
||||
@@ -66,6 +77,16 @@ pub struct PluginField {
|
||||
pub sortable: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub visible_when: Option<String>,
|
||||
pub ref_entity: Option<String>, // 外键引用的实体名
|
||||
pub ref_label_field: Option<String>, // entity_select 下拉显示的字段名
|
||||
pub ref_search_fields: Option<Vec<String>>, // entity_select 搜索匹配的字段列表
|
||||
pub cascade_from: Option<String>, // 级联过滤的来源字段(当前实体)
|
||||
pub cascade_filter: Option<String>, // 级联过滤的目标字段(引用实体的字段)
|
||||
pub validation: Option<FieldValidation>, // 字段校验规则
|
||||
#[serde(default)]
|
||||
pub no_cycle: Option<bool>, // 禁止循环引用
|
||||
#[serde(default)]
|
||||
pub scope_role: Option<String>, // 标记为数据权限的"所有者"字段
|
||||
}
|
||||
|
||||
/// 字段类型
|
||||
@@ -129,6 +150,14 @@ impl PluginField {
|
||||
filterable: None,
|
||||
sortable: None,
|
||||
visible_when: None,
|
||||
ref_entity: None,
|
||||
ref_label_field: None,
|
||||
ref_search_fields: None,
|
||||
cascade_from: None,
|
||||
cascade_filter: None,
|
||||
validation: None,
|
||||
no_cycle: None,
|
||||
scope_role: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +171,23 @@ pub struct PluginIndex {
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
/// 级联删除策略
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnDeleteStrategy {
|
||||
Nullify, // 置空外键字段
|
||||
Cascade, // 级联软删除
|
||||
Restrict, // 存在关联时拒绝删除
|
||||
}
|
||||
|
||||
/// 实体关联关系声明
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginRelation {
|
||||
pub entity: String,
|
||||
pub foreign_key: String,
|
||||
pub on_delete: OnDeleteStrategy,
|
||||
}
|
||||
|
||||
/// 事件订阅配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginEvents {
|
||||
@@ -210,6 +256,23 @@ pub enum PluginPageType {
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
#[serde(rename = "kanban")]
|
||||
Kanban {
|
||||
entity: String,
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
lane_field: String,
|
||||
#[serde(default)]
|
||||
lane_order: Vec<String>,
|
||||
card_title_field: String,
|
||||
#[serde(default)]
|
||||
card_subtitle_field: Option<String>,
|
||||
#[serde(default)]
|
||||
card_fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
enable_drag: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 插件页面区段(用于 detail 页面类型)
|
||||
@@ -239,6 +302,8 @@ pub struct PluginPermission {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub data_scope_levels: Option<Vec<String>>, // 支持的数据范围等级
|
||||
}
|
||||
|
||||
/// 从 TOML 字符串解析插件清单
|
||||
@@ -352,6 +417,28 @@ fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> {
|
||||
PluginPageType::Dashboard { .. } => {
|
||||
// dashboard 无需额外验证
|
||||
}
|
||||
PluginPageType::Kanban {
|
||||
entity,
|
||||
lane_field,
|
||||
card_title_field,
|
||||
..
|
||||
} => {
|
||||
if entity.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"kanban page 的 entity 不能为空".into(),
|
||||
));
|
||||
}
|
||||
if lane_field.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"kanban page 的 lane_field 不能为空".into(),
|
||||
));
|
||||
}
|
||||
if card_title_field.is_empty() {
|
||||
return Err(PluginError::InvalidManifest(
|
||||
"kanban page 的 card_title_field 不能为空".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -599,4 +686,323 @@ label = "空标签页"
|
||||
assert_eq!(PluginFieldType::Integer.generated_expr("age"), "(data->>'age')::INTEGER");
|
||||
assert_eq!(PluginFieldType::Uuid.generated_expr("ref_id"), "(data->>'ref_id')::UUID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_field_with_ref_entity() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "contact"
|
||||
display_name = "联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ref_entity = "customer"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||||
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_field_with_validation() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "contact"
|
||||
display_name = "联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "phone"
|
||||
field_type = "string"
|
||||
display_name = "手机号"
|
||||
validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" }
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||||
let v = field.validation.as_ref().unwrap();
|
||||
assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$"));
|
||||
assert_eq!(v.message.as_deref(), Some("手机号格式不正确"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_field_with_no_cycle() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "parent_id"
|
||||
field_type = "uuid"
|
||||
display_name = "上级客户"
|
||||
ref_entity = "customer"
|
||||
no_cycle = true
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||||
assert_eq!(field.no_cycle, Some(true));
|
||||
assert_eq!(field.ref_entity.as_deref(), Some("customer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_entity_with_relations() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "code"
|
||||
field_type = "string"
|
||||
required = true
|
||||
display_name = "编码"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "contact"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
|
||||
[[schema.entities.relations]]
|
||||
entity = "customer_tag"
|
||||
foreign_key = "customer_id"
|
||||
on_delete = "cascade"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let entity = &manifest.schema.unwrap().entities[0];
|
||||
assert_eq!(entity.relations.len(), 2);
|
||||
assert_eq!(entity.relations[0].entity, "contact");
|
||||
assert_eq!(entity.relations[0].foreign_key, "customer_id");
|
||||
assert!(matches!(entity.relations[0].on_delete, OnDeleteStrategy::Cascade));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_entity_with_data_scope() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "customer"
|
||||
display_name = "客户"
|
||||
data_scope = true
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "owner_id"
|
||||
field_type = "uuid"
|
||||
display_name = "负责人"
|
||||
scope_role = "owner"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let entity = &manifest.schema.unwrap().entities[0];
|
||||
assert_eq!(entity.data_scope, Some(true));
|
||||
assert_eq!(entity.fields[0].scope_role.as_deref(), Some("owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_permission_with_data_scope_levels() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[[permissions]]
|
||||
code = "customer.list"
|
||||
name = "查看客户"
|
||||
data_scope_levels = ["self", "department", "department_tree", "all"]
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let perm = &manifest.permissions.unwrap()[0];
|
||||
assert_eq!(perm.data_scope_levels.as_ref().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_field_with_entity_select() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "contact"
|
||||
display_name = "联系人"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "所属客户"
|
||||
ui_widget = "entity_select"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name", "code"]
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let field = &manifest.schema.unwrap().entities[0].fields[0];
|
||||
assert_eq!(field.ui_widget.as_deref(), Some("entity_select"));
|
||||
assert_eq!(field.ref_label_field.as_deref(), Some("name"));
|
||||
assert_eq!(
|
||||
field.ref_search_fields.as_deref(),
|
||||
Some(
|
||||
&["name".to_string(), "code".to_string()][..]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_field_with_cascade() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[schema]
|
||||
[[schema.entities]]
|
||||
name = "communication"
|
||||
display_name = "沟通记录"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "customer_id"
|
||||
field_type = "uuid"
|
||||
required = true
|
||||
display_name = "关联客户"
|
||||
|
||||
[[schema.entities.fields]]
|
||||
name = "contact_id"
|
||||
field_type = "uuid"
|
||||
display_name = "关联联系人"
|
||||
ui_widget = "entity_select"
|
||||
ref_label_field = "name"
|
||||
ref_search_fields = ["name"]
|
||||
cascade_from = "customer_id"
|
||||
cascade_filter = "customer_id"
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let fields = &manifest.schema.unwrap().entities[0].fields;
|
||||
let contact_field = &fields[1];
|
||||
assert_eq!(contact_field.ui_widget.as_deref(), Some("entity_select"));
|
||||
assert_eq!(contact_field.cascade_from.as_deref(), Some("customer_id"));
|
||||
assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kanban_page() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = "customer"
|
||||
label = "销售漏斗"
|
||||
icon = "swap"
|
||||
lane_field = "level"
|
||||
lane_order = ["potential", "normal", "vip", "svip"]
|
||||
card_title_field = "name"
|
||||
card_subtitle_field = "code"
|
||||
card_fields = ["region", "status"]
|
||||
enable_drag = true
|
||||
"#;
|
||||
let manifest = parse_manifest(toml).unwrap();
|
||||
let ui = manifest.ui.unwrap();
|
||||
assert_eq!(ui.pages.len(), 1);
|
||||
match &ui.pages[0] {
|
||||
PluginPageType::Kanban {
|
||||
entity,
|
||||
label,
|
||||
icon,
|
||||
lane_field,
|
||||
lane_order,
|
||||
card_title_field,
|
||||
card_subtitle_field,
|
||||
card_fields,
|
||||
enable_drag,
|
||||
} => {
|
||||
assert_eq!(entity, "customer");
|
||||
assert_eq!(label, "销售漏斗");
|
||||
assert_eq!(icon.as_deref(), Some("swap"));
|
||||
assert_eq!(lane_field, "level");
|
||||
assert_eq!(lane_order, &["potential", "normal", "vip", "svip"]);
|
||||
assert_eq!(card_title_field, "name");
|
||||
assert_eq!(card_subtitle_field.as_deref(), Some("code"));
|
||||
assert_eq!(card_fields, &["region", "status"]);
|
||||
assert_eq!(*enable_drag, Some(true));
|
||||
}
|
||||
_ => panic!("Expected Kanban page type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_empty_entity_in_kanban_page() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = ""
|
||||
label = "测试"
|
||||
lane_field = "status"
|
||||
card_title_field = "name"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_empty_lane_field_in_kanban_page() {
|
||||
let toml = r#"
|
||||
[metadata]
|
||||
id = "test"
|
||||
name = "Test"
|
||||
version = "0.1.0"
|
||||
|
||||
[ui]
|
||||
[[ui.pages]]
|
||||
type = "kanban"
|
||||
entity = "customer"
|
||||
label = "测试"
|
||||
lane_field = ""
|
||||
card_title_field = "name"
|
||||
"#;
|
||||
let result = parse_manifest(toml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ impl PluginModule {
|
||||
"/plugins/{plugin_id}/{entity}/{id}",
|
||||
get(crate::handler::data_handler::get_plugin_data::<S>)
|
||||
.put(crate::handler::data_handler::update_plugin_data::<S>)
|
||||
.patch(crate::handler::data_handler::patch_plugin_data::<S>)
|
||||
.delete(crate::handler::data_handler::delete_plugin_data::<S>),
|
||||
)
|
||||
// 数据统计路由
|
||||
@@ -85,6 +86,16 @@ impl PluginModule {
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}/aggregate",
|
||||
get(crate::handler::data_handler::aggregate_plugin_data::<S>),
|
||||
)
|
||||
// 批量操作路由
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}/batch",
|
||||
post(crate::handler::data_handler::batch_plugin_data::<S>),
|
||||
)
|
||||
// 时间序列路由
|
||||
.route(
|
||||
"/plugins/{plugin_id}/{entity}/timeseries",
|
||||
get(crate::handler::data_handler::get_plugin_timeseries::<S>),
|
||||
);
|
||||
|
||||
admin_routes.merge(data_routes)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use moka::sync::Cache;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use erp_core::error::{AppError, AppResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
use crate::engine::PluginEngine;
|
||||
@@ -10,4 +14,39 @@ pub struct PluginState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub engine: PluginEngine,
|
||||
/// Schema 缓存 — key: "{plugin_id}:{entity_name}:{tenant_id}"
|
||||
pub entity_cache: Cache<String, EntityInfo>,
|
||||
}
|
||||
|
||||
/// 缓存的实体信息
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntityInfo {
|
||||
pub table_name: String,
|
||||
pub schema_json: serde_json::Value,
|
||||
pub generated_fields: Vec<String>,
|
||||
}
|
||||
|
||||
impl EntityInfo {
|
||||
/// 从 schema_json 解析字段列表
|
||||
pub fn fields(&self) -> AppResult<Vec<crate::manifest::PluginField>> {
|
||||
let entity_def: crate::manifest::PluginEntity =
|
||||
serde_json::from_value(self.schema_json.clone())
|
||||
.map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?;
|
||||
Ok(entity_def.fields)
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginState {
|
||||
pub fn new(db: DatabaseConnection, event_bus: EventBus, engine: PluginEngine) -> Self {
|
||||
let entity_cache = Cache::builder()
|
||||
.max_capacity(1000)
|
||||
.time_to_idle(Duration::from_secs(300))
|
||||
.build();
|
||||
Self {
|
||||
db,
|
||||
event_bus,
|
||||
engine,
|
||||
entity_cache,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,3 +30,4 @@ erp-plugin.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
|
||||
@@ -35,6 +35,7 @@ mod m20260416_000031_create_domain_events;
|
||||
mod m20260417_000033_create_plugins;
|
||||
mod m20260417_000034_seed_plugin_permissions;
|
||||
mod m20260418_000035_pg_trgm_and_entity_columns;
|
||||
mod m20260418_000036_add_data_scope_to_role_permissions;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -77,6 +78,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260417_000033_create_plugins::Migration),
|
||||
Box::new(m20260417_000034_seed_plugin_permissions::Migration),
|
||||
Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration),
|
||||
Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 添加 data_scope 列 — 行级数据权限范围
|
||||
// 可选值: all, self, department, department_tree
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("role_permissions"))
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("data_scope"))
|
||||
.string()
|
||||
.not_null()
|
||||
.default("all"),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Alias::new("role_permissions"))
|
||||
.drop_column(Alias::new("data_scope"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -371,6 +371,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
redis: redis_client.clone(),
|
||||
default_tenant_id,
|
||||
plugin_engine,
|
||||
plugin_entity_cache: moka::sync::Cache::builder()
|
||||
.max_capacity(1000)
|
||||
.time_to_idle(std::time::Duration::from_secs(300))
|
||||
.build(),
|
||||
};
|
||||
|
||||
// --- Build the router ---
|
||||
|
||||
@@ -18,6 +18,8 @@ pub struct AppState {
|
||||
pub default_tenant_id: uuid::Uuid,
|
||||
/// 插件引擎
|
||||
pub plugin_engine: erp_plugin::engine::PluginEngine,
|
||||
/// 插件实体缓存
|
||||
pub plugin_entity_cache: moka::sync::Cache<String, erp_plugin::state::EntityInfo>,
|
||||
}
|
||||
|
||||
/// Allow handlers to extract `DatabaseConnection` directly from `State<AppState>`.
|
||||
@@ -90,6 +92,7 @@ impl FromRef<AppState> for erp_plugin::state::PluginState {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
engine: state.plugin_engine.clone(),
|
||||
entity_cache: state.plugin_entity_cache.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user