替换ATP渲染为AntV X6并移除maxgraph
This commit is contained in:
@@ -981,12 +981,13 @@
|
||||
- 模型与 seed:`file_storage_backends` / `file_storage_mounts` / `file_index_entries` 已恢复注册;默认 seed 会创建 `files.vfs.default`、`files.s3.default` 与 `main` 挂载。
|
||||
- 交互口径:页面保持“单挂载点”模式,不展示左侧挂载点切换面板。
|
||||
|
||||
## ATP 查看器口径(2026-04-26)
|
||||
## ATP 查看器口径(2026-04-26,2026-05-04 更新)
|
||||
|
||||
- ATP 文本查看能力已落地在线路模块子路由:`/power-lines/atp-viewer`(内部路由 `web/src/app/admin/power-lines/atp-viewer/page.tsx`)。
|
||||
- 技术栈固定为“前端本地解析 + 前端只读渲染”:
|
||||
- 解析:`web/src/lib/atp/parse-atp-text.ts`
|
||||
- 渲染:`@maxgraph/core` + `web/src/components/atp-maxgraph-viewer.tsx`
|
||||
- 渲染:`@antv/x6` + `web/src/components/atp-x6-viewer.tsx`
|
||||
- 电气元件符号:组件内置 SVG data-uri 映射(R/L/C/SW/SRC/XFMR/LINE/CTRL/MISC)。
|
||||
- 当前目标是“查看保真优先”,明确不包含仿真内核调用。
|
||||
- 解析覆盖常见元件行格式,复杂 ATP 控制卡/模型卡默认容错跳过并输出 warnings,不阻断基础图形查看。
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
## Work Log - ATP 渲染从 maxGraph 切换到 AntV X6(2026-05-04)
|
||||
|
||||
- 背景:
|
||||
- 用户要求“直接改成 AntV X6 + 一套电气符号 SVG 方案,去掉 maxgraph 方案”。
|
||||
|
||||
- 本次改动(最小闭环):
|
||||
- 前端 ATP 渲染组件整体替换:
|
||||
- 删除:`web/src/components/atp-maxgraph-viewer.tsx`
|
||||
- 新增:`web/src/components/atp-x6-viewer.tsx`
|
||||
- 方案:`@antv/x6` 只读渲染 + 自定义电气符号 SVG(按元件类型 R/L/C/SW/SRC/XFMR/LINE/CTRL/MISC 映射)
|
||||
- 页面引用与文案同步:
|
||||
- 文件:`web/src/app/admin/power-lines/atp-viewer/page.tsx`
|
||||
- 组件引用从 `AtpMaxGraphViewer` 改为 `AtpX6Viewer`
|
||||
- 卡片标题从“maxGraph 渲染结果”改为“X6 渲染结果”
|
||||
- 依赖替换:
|
||||
- `web/package.json`:移除 `@maxgraph/core`,新增 `@antv/x6`
|
||||
- 锁文件同步:`web/package-lock.json`、根 `package-lock.json`
|
||||
|
||||
- 验证:
|
||||
- `npm run build:web` 通过(含编译、TypeScript、静态页生成)。
|
||||
- `npm run lint:web` 当前工程基线仍有大量历史问题(含 Cesium 产物目录与其他页面告警/错误),本次未新增针对性 lint 规则改动。
|
||||
|
||||
- 风险与影响:
|
||||
- 影响面限定在 ATP 查看器前端渲染层与前端依赖。
|
||||
- ATP 图布局策略沿用原有节点位置推导逻辑;渲染引擎替换后视觉细节(标签锚点/线段路由)会与旧 maxGraph 有差异。
|
||||
- `web/.next/lock` 在部分异常终止场景可能残留,已在本次验证过程中手动清理后重跑构建。
|
||||
Generated
+43
-7
@@ -108,6 +108,21 @@
|
||||
"react": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antv/x6": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@antv/x6/-/x6-3.1.7.tgz",
|
||||
"integrity": "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dom-align": "^1.12.4",
|
||||
"lodash-es": "^4.17.15",
|
||||
"mousetrap": "^1.6.5",
|
||||
"utility-types": "^3.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
@@ -180,12 +195,6 @@
|
||||
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@maxgraph/core": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@maxgraph/core/-/core-0.23.0.tgz",
|
||||
"integrity": "sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -629,6 +638,12 @@
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-align": {
|
||||
"version": "1.12.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
|
||||
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||
@@ -719,6 +734,12 @@
|
||||
"integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -752,6 +773,12 @@
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mousetrap": {
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz",
|
||||
"integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==",
|
||||
"license": "Apache-2.0 WITH LLVM-exception"
|
||||
},
|
||||
"node_modules/nosleep.js": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
|
||||
@@ -1521,6 +1548,15 @@
|
||||
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utility-types": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/web": {
|
||||
"resolved": "web",
|
||||
"link": true
|
||||
@@ -1529,7 +1565,7 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@maxgraph/core": "^0.23.0",
|
||||
"@antv/x6": "^3.1.7",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"antd": "^5.29.3",
|
||||
"cesium": "^1.140.0",
|
||||
|
||||
Generated
+43
-7
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@maxgraph/core": "^0.23.0",
|
||||
"@antv/x6": "^3.1.7",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"antd": "^5.29.3",
|
||||
"cesium": "^1.140.0",
|
||||
@@ -140,6 +140,21 @@
|
||||
"react": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antv/x6": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@antv/x6/-/x6-3.1.7.tgz",
|
||||
"integrity": "sha512-NLKXtbCK51oLbazfFD0XsD93rMmih08UBW4gAuEyLBpwAqHmHe+vP8VhOZDkl5O9jV1LSv85IJghr9CT5tZjWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dom-align": "^1.12.4",
|
||||
"lodash-es": "^4.17.15",
|
||||
"mousetrap": "^1.6.5",
|
||||
"utility-types": "^3.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"dev": true,
|
||||
@@ -670,12 +685,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@maxgraph/core": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@maxgraph/core/-/core-0.23.0.tgz",
|
||||
"integrity": "sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.3",
|
||||
"license": "MIT"
|
||||
@@ -2257,6 +2266,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-align": {
|
||||
"version": "1.12.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
|
||||
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||
@@ -3926,6 +3941,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"dev": true,
|
||||
@@ -4038,6 +4059,12 @@
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mousetrap": {
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz",
|
||||
"integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==",
|
||||
"license": "Apache-2.0 WITH LLVM-exception"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"dev": true,
|
||||
@@ -6090,6 +6117,15 @@
|
||||
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utility-types": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"dev": true,
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maxgraph/core": "^0.23.0",
|
||||
"@antv/x6": "^3.1.7",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"antd": "^5.29.3",
|
||||
"cesium": "^1.140.0",
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { AtpMaxGraphViewer } from "@/components/atp-maxgraph-viewer";
|
||||
import { AtpX6Viewer } from "@/components/atp-x6-viewer";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
|
||||
import { readApiError } from "@/lib/api";
|
||||
@@ -1223,8 +1223,8 @@ export default function PowerLinesAtpViewerPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="maxGraph 渲染结果">
|
||||
<AtpMaxGraphViewer graph={graphJson} />
|
||||
<Card title="X6 渲染结果">
|
||||
<AtpX6Viewer graph={graphJson} />
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Button, Empty, Space, Typography } from "antd";
|
||||
import type { Graph } from "@maxgraph/core";
|
||||
|
||||
import type { AtpGraphEdge, AtpGraphJson, AtpGraphNode } from "@/lib/atp/types";
|
||||
|
||||
type AtpMaxGraphViewerProps = {
|
||||
graph: AtpGraphJson | null;
|
||||
};
|
||||
|
||||
type NodePosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const H_SPACING = 220;
|
||||
const V_SPACING = 128;
|
||||
|
||||
const BUS_STYLE = {
|
||||
shape: "rectangle",
|
||||
rounded: true,
|
||||
strokeColor: "#2f54eb",
|
||||
fillColor: "#e6f4ff",
|
||||
fontColor: "#10239e",
|
||||
strokeWidth: 1.2,
|
||||
fontSize: 12,
|
||||
whiteSpace: "wrap",
|
||||
spacing: 4,
|
||||
} as const;
|
||||
|
||||
const GROUND_STYLE = {
|
||||
shape: "triangle",
|
||||
direction: "north",
|
||||
strokeColor: "#7f8c8d",
|
||||
fillColor: "#f5f5f5",
|
||||
fontColor: "#262626",
|
||||
strokeWidth: 1,
|
||||
fontSize: 11,
|
||||
} as const;
|
||||
|
||||
const EDGE_STYLE = {
|
||||
shape: "connector",
|
||||
edgeStyle: "orthogonalEdgeStyle",
|
||||
orthogonalLoop: true,
|
||||
rounded: true,
|
||||
strokeColor: "#434343",
|
||||
strokeWidth: 1.4,
|
||||
endArrow: "none",
|
||||
fontColor: "#262626",
|
||||
fontSize: 11,
|
||||
labelBackgroundColor: "#ffffff",
|
||||
labelBorderColor: "#f0f0f0",
|
||||
} as const;
|
||||
|
||||
function buildEdgeLabel(edge: AtpGraphEdge): string {
|
||||
return edge.value ? `${edge.name} (${edge.kind}) ${edge.value}` : `${edge.name} (${edge.kind})`;
|
||||
}
|
||||
|
||||
function positionKey(position: NodePosition): string {
|
||||
return `${position.x}:${position.y}`;
|
||||
}
|
||||
|
||||
function reservePosition(occupied: Set<string>, preferred: NodePosition): NodePosition {
|
||||
if (!occupied.has(positionKey(preferred))) {
|
||||
occupied.add(positionKey(preferred));
|
||||
return preferred;
|
||||
}
|
||||
|
||||
for (let offset = 1; offset <= 100; offset += 1) {
|
||||
const candidateUp = { x: preferred.x, y: preferred.y - offset * V_SPACING };
|
||||
if (!occupied.has(positionKey(candidateUp))) {
|
||||
occupied.add(positionKey(candidateUp));
|
||||
return candidateUp;
|
||||
}
|
||||
|
||||
const candidateDown = { x: preferred.x, y: preferred.y + offset * V_SPACING };
|
||||
if (!occupied.has(positionKey(candidateDown))) {
|
||||
occupied.add(positionKey(candidateDown));
|
||||
return candidateDown;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = {
|
||||
x: preferred.x,
|
||||
y: preferred.y + (occupied.size + 1) * V_SPACING,
|
||||
};
|
||||
occupied.add(positionKey(fallback));
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function computeNodePositions(nodes: AtpGraphNode[], edges: AtpGraphEdge[]): Map<string, NodePosition> {
|
||||
const positions = new Map<string, NodePosition>();
|
||||
const occupied = new Set<string>();
|
||||
|
||||
let nextSeedY = 0;
|
||||
|
||||
for (const edge of edges) {
|
||||
const sourcePosition = positions.get(edge.source);
|
||||
const targetPosition = positions.get(edge.target);
|
||||
|
||||
if (!sourcePosition && !targetPosition) {
|
||||
const source = reservePosition(occupied, { x: 0, y: nextSeedY });
|
||||
const target = reservePosition(occupied, { x: H_SPACING, y: nextSeedY });
|
||||
positions.set(edge.source, source);
|
||||
positions.set(edge.target, target);
|
||||
nextSeedY += V_SPACING * 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourcePosition && !targetPosition) {
|
||||
const target = reservePosition(occupied, { x: sourcePosition.x + H_SPACING, y: sourcePosition.y });
|
||||
positions.set(edge.target, target);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sourcePosition && targetPosition) {
|
||||
const source = reservePosition(occupied, { x: targetPosition.x - H_SPACING, y: targetPosition.y });
|
||||
positions.set(edge.source, source);
|
||||
}
|
||||
}
|
||||
|
||||
const unplaced = nodes.filter((node) => !positions.has(node.id));
|
||||
const columns = 4;
|
||||
let col = 0;
|
||||
let row = 0;
|
||||
|
||||
for (const node of unplaced) {
|
||||
const preferred = {
|
||||
x: col * H_SPACING,
|
||||
y: nextSeedY + row * V_SPACING,
|
||||
};
|
||||
positions.set(node.id, reservePosition(occupied, preferred));
|
||||
|
||||
col += 1;
|
||||
if (col >= columns) {
|
||||
col = 0;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function AtpMaxGraphViewer({ graph }: AtpMaxGraphViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
|
||||
const positions = useMemo(() => {
|
||||
if (!graph) {
|
||||
return new Map<string, NodePosition>();
|
||||
}
|
||||
return computeNodePositions(graph.nodes, graph.edges);
|
||||
}, [graph]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
let disposed = false;
|
||||
let createdGraph: Graph | null = null;
|
||||
|
||||
async function renderGraph() {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
setRenderError("");
|
||||
|
||||
if (!graph || graph.nodes.length === 0 || graph.edges.length === 0) {
|
||||
graphRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const maxgraph = await import("@maxgraph/core");
|
||||
if (disposed || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new maxgraph.Graph(container);
|
||||
createdGraph = instance;
|
||||
graphRef.current = instance;
|
||||
|
||||
instance.setEnabled(false);
|
||||
instance.setCellsEditable(false);
|
||||
instance.setCellsMovable(false);
|
||||
instance.setCellsResizable(false);
|
||||
instance.setCellsBendable(false);
|
||||
instance.setCellsSelectable(true);
|
||||
instance.setConnectable(false);
|
||||
instance.setPanning(true);
|
||||
instance.setTooltips(true);
|
||||
|
||||
const parent = instance.getDefaultParent();
|
||||
const nodeCells = new Map<string, ReturnType<Graph["insertVertex"]>>();
|
||||
|
||||
instance.batchUpdate(() => {
|
||||
for (const node of graph.nodes) {
|
||||
const position = positions.get(node.id) ?? { x: 0, y: 0 };
|
||||
const isGround = node.kind === "ground";
|
||||
const vertex = instance.insertVertex({
|
||||
parent,
|
||||
id: `node_${node.id}`,
|
||||
value: node.label,
|
||||
position: [position.x, position.y],
|
||||
size: isGround ? [80, 56] : [140, 46],
|
||||
style: isGround ? GROUND_STYLE : BUS_STYLE,
|
||||
});
|
||||
nodeCells.set(node.id, vertex);
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
const source = nodeCells.get(edge.source);
|
||||
const target = nodeCells.get(edge.target);
|
||||
if (!source || !target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
instance.insertEdge({
|
||||
parent,
|
||||
id: edge.id,
|
||||
value: buildEdgeLabel(edge),
|
||||
source,
|
||||
target,
|
||||
style: EDGE_STYLE,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
instance.zoomActual();
|
||||
instance.center(true, true);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "maxGraph 渲染失败";
|
||||
setRenderError(detail);
|
||||
}
|
||||
}
|
||||
|
||||
void renderGraph();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (createdGraph) {
|
||||
createdGraph.destroy();
|
||||
}
|
||||
if (graphRef.current === createdGraph) {
|
||||
graphRef.current = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
}
|
||||
};
|
||||
}, [graph, positions]);
|
||||
|
||||
const handleFit = () => {
|
||||
const instance = graphRef.current;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = instance.getGraphBounds();
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.zoomToRect(bounds);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
graphRef.current?.zoomIn();
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
graphRef.current?.zoomOut();
|
||||
};
|
||||
|
||||
const hasRenderableGraph = !!graph && graph.nodes.length > 0 && graph.edges.length > 0;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} className="w-full">
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" onClick={handleFit} disabled={!hasRenderableGraph}>
|
||||
适配视图
|
||||
</Button>
|
||||
<Button size="small" onClick={handleZoomIn} disabled={!hasRenderableGraph}>
|
||||
放大
|
||||
</Button>
|
||||
<Button size="small" onClick={handleZoomOut} disabled={!hasRenderableGraph}>
|
||||
缩小
|
||||
</Button>
|
||||
{graph && (
|
||||
<Typography.Text type="secondary">
|
||||
节点 {graph.stats.node_count} / 元件 {graph.stats.element_count}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{renderError && <Alert type="error" showIcon message="渲染失败" description={renderError} />}
|
||||
|
||||
<div className="relative min-h-[560px] w-full overflow-hidden rounded border border-gray-200 bg-[#fcfdff]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-[560px] w-full"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, rgba(31, 35, 41, 0.08) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
/>
|
||||
{!hasRenderableGraph && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<Empty description="暂无可渲染图形,先完成 ATP 转换。" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Button, Empty, Space, Typography } from "antd";
|
||||
import type { Graph as X6Graph } from "@antv/x6";
|
||||
|
||||
import type {
|
||||
AtpElementKind,
|
||||
AtpGraphEdge,
|
||||
AtpGraphJson,
|
||||
AtpGraphNode,
|
||||
} from "@/lib/atp/types";
|
||||
|
||||
type AtpX6ViewerProps = {
|
||||
graph: AtpGraphJson | null;
|
||||
};
|
||||
|
||||
type NodePosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const H_SPACING = 220;
|
||||
const V_SPACING = 128;
|
||||
|
||||
const BUS_NODE_STYLE = {
|
||||
body: {
|
||||
fill: "#e6f4ff",
|
||||
stroke: "#2f54eb",
|
||||
strokeWidth: 1.2,
|
||||
rx: 8,
|
||||
ry: 8,
|
||||
},
|
||||
label: {
|
||||
fill: "#10239e",
|
||||
fontSize: 12,
|
||||
textAnchor: "middle",
|
||||
textVerticalAnchor: "middle",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const GROUND_NODE_STYLE = {
|
||||
body: {
|
||||
fill: "#f5f5f5",
|
||||
stroke: "#7f8c8d",
|
||||
strokeWidth: 1,
|
||||
},
|
||||
label: {
|
||||
fill: "#262626",
|
||||
fontSize: 11,
|
||||
textAnchor: "middle",
|
||||
textVerticalAnchor: "middle",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EDGE_STYLE = {
|
||||
line: {
|
||||
stroke: "#434343",
|
||||
strokeWidth: 1.4,
|
||||
strokeLinecap: "round",
|
||||
sourceMarker: null,
|
||||
targetMarker: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EDGE_LABEL_MARKUP = [
|
||||
{
|
||||
tagName: "rect",
|
||||
selector: "panel",
|
||||
},
|
||||
{
|
||||
tagName: "image",
|
||||
selector: "symbol",
|
||||
},
|
||||
{
|
||||
tagName: "text",
|
||||
selector: "label",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const EDGE_KIND_COLORS: Record<AtpElementKind, string> = {
|
||||
R: "#cf1322",
|
||||
L: "#0958d9",
|
||||
C: "#531dab",
|
||||
SW: "#389e0d",
|
||||
SRC: "#fa8c16",
|
||||
XFMR: "#08979c",
|
||||
LINE: "#595959",
|
||||
CTRL: "#d46b08",
|
||||
MISC: "#262626",
|
||||
};
|
||||
|
||||
const EDGE_KINDS: AtpElementKind[] = [
|
||||
"R",
|
||||
"L",
|
||||
"C",
|
||||
"SW",
|
||||
"SRC",
|
||||
"XFMR",
|
||||
"LINE",
|
||||
"CTRL",
|
||||
"MISC",
|
||||
];
|
||||
|
||||
const EDGE_SYMBOL_DATA_URI = EDGE_KINDS.reduce(
|
||||
(acc, kind) => {
|
||||
acc[kind] = buildEdgeSymbolDataUri(kind);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<AtpElementKind, string>,
|
||||
);
|
||||
|
||||
function buildEdgeLabelText(edge: AtpGraphEdge): string {
|
||||
return edge.value ? `${edge.name} (${edge.kind}) ${edge.value}` : `${edge.name} (${edge.kind})`;
|
||||
}
|
||||
|
||||
function edgeSymbolGlyph(kind: AtpElementKind, color: string): string {
|
||||
switch (kind) {
|
||||
case "R":
|
||||
return `<polyline points="18,16 22,11 26,21 30,11 34,21 38,11 42,16" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||
case "L":
|
||||
return `<path d="M 18 16 C 20 8, 24 8, 26 16 C 28 8, 32 8, 34 16 C 36 8, 40 8, 42 16" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
|
||||
case "C":
|
||||
return `<line x1="26" y1="9" x2="26" y2="23" stroke="${color}" stroke-width="2"/><line x1="38" y1="9" x2="38" y2="23" stroke="${color}" stroke-width="2"/>`;
|
||||
case "SW":
|
||||
return `<circle cx="24" cy="16" r="1.8" fill="${color}"/><circle cx="40" cy="16" r="1.8" fill="${color}"/><line x1="24" y1="16" x2="40" y2="10" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
|
||||
case "SRC":
|
||||
return `<circle cx="32" cy="16" r="8" fill="none" stroke="${color}" stroke-width="2"/><path d="M 27 16 C 28.5 12.5, 30.5 12.5, 32 16 C 33.5 19.5, 35.5 19.5, 37 16" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/>`;
|
||||
case "XFMR":
|
||||
return `<path d="M 24 10 C 22 12, 22 20, 24 22 C 26 20, 26 12, 24 10" fill="none" stroke="${color}" stroke-width="2"/><path d="M 32 10 C 30 12, 30 20, 32 22 C 34 20, 34 12, 32 10" fill="none" stroke="${color}" stroke-width="2"/><path d="M 40 10 C 38 12, 38 20, 40 22 C 42 20, 42 12, 40 10" fill="none" stroke="${color}" stroke-width="2"/>`;
|
||||
case "LINE":
|
||||
return `<line x1="24" y1="9" x2="40" y2="23" stroke="${color}" stroke-width="2"/><line x1="24" y1="23" x2="40" y2="9" stroke="${color}" stroke-width="2"/>`;
|
||||
case "CTRL":
|
||||
return `<rect x="23" y="10" width="18" height="12" rx="3" ry="3" fill="none" stroke="${color}" stroke-width="2"/><text x="32" y="19" text-anchor="middle" font-size="8" font-family="Arial, sans-serif" fill="${color}">C</text>`;
|
||||
case "MISC":
|
||||
default:
|
||||
return `<polygon points="32,9 40,16 32,23 24,16" fill="none" stroke="${color}" stroke-width="2"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeSvgAsDataUri(svg: string): string {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function buildEdgeSymbolDataUri(kind: AtpElementKind): string {
|
||||
const color = EDGE_KIND_COLORS[kind];
|
||||
const glyph = edgeSymbolGlyph(kind, color);
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"><line x1="2" y1="16" x2="18" y2="16" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/><line x1="46" y1="16" x2="62" y2="16" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/>${glyph}</svg>`;
|
||||
return encodeSvgAsDataUri(svg);
|
||||
}
|
||||
|
||||
function buildEdgeLabel(edge: AtpGraphEdge) {
|
||||
return {
|
||||
markup: [...EDGE_LABEL_MARKUP],
|
||||
attrs: {
|
||||
panel: {
|
||||
ref: "label",
|
||||
refWidth: "118%",
|
||||
refHeight: "190%",
|
||||
refX: "-9%",
|
||||
refY: "-75%",
|
||||
fill: "rgba(255,255,255,0.9)",
|
||||
stroke: "#e5e7eb",
|
||||
strokeWidth: 1,
|
||||
rx: 4,
|
||||
ry: 4,
|
||||
},
|
||||
symbol: {
|
||||
xlinkHref: EDGE_SYMBOL_DATA_URI[edge.kind],
|
||||
width: 48,
|
||||
height: 24,
|
||||
x: -24,
|
||||
y: -30,
|
||||
preserveAspectRatio: "xMidYMid meet",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
label: {
|
||||
text: buildEdgeLabelText(edge),
|
||||
fill: "#262626",
|
||||
fontSize: 11,
|
||||
fontFamily: "JetBrains Mono, Menlo, monospace",
|
||||
textAnchor: "middle",
|
||||
textVerticalAnchor: "middle",
|
||||
y: 4,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
},
|
||||
position: {
|
||||
distance: 0.5,
|
||||
offset: -18,
|
||||
options: {
|
||||
keepGradient: false,
|
||||
ensureLegibility: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function positionKey(position: NodePosition): string {
|
||||
return `${position.x}:${position.y}`;
|
||||
}
|
||||
|
||||
function reservePosition(occupied: Set<string>, preferred: NodePosition): NodePosition {
|
||||
if (!occupied.has(positionKey(preferred))) {
|
||||
occupied.add(positionKey(preferred));
|
||||
return preferred;
|
||||
}
|
||||
|
||||
for (let offset = 1; offset <= 100; offset += 1) {
|
||||
const candidateUp = { x: preferred.x, y: preferred.y - offset * V_SPACING };
|
||||
if (!occupied.has(positionKey(candidateUp))) {
|
||||
occupied.add(positionKey(candidateUp));
|
||||
return candidateUp;
|
||||
}
|
||||
|
||||
const candidateDown = { x: preferred.x, y: preferred.y + offset * V_SPACING };
|
||||
if (!occupied.has(positionKey(candidateDown))) {
|
||||
occupied.add(positionKey(candidateDown));
|
||||
return candidateDown;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = {
|
||||
x: preferred.x,
|
||||
y: preferred.y + (occupied.size + 1) * V_SPACING,
|
||||
};
|
||||
occupied.add(positionKey(fallback));
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function computeNodePositions(nodes: AtpGraphNode[], edges: AtpGraphEdge[]): Map<string, NodePosition> {
|
||||
const positions = new Map<string, NodePosition>();
|
||||
const occupied = new Set<string>();
|
||||
|
||||
let nextSeedY = 0;
|
||||
|
||||
for (const edge of edges) {
|
||||
const sourcePosition = positions.get(edge.source);
|
||||
const targetPosition = positions.get(edge.target);
|
||||
|
||||
if (!sourcePosition && !targetPosition) {
|
||||
const source = reservePosition(occupied, { x: 0, y: nextSeedY });
|
||||
const target = reservePosition(occupied, { x: H_SPACING, y: nextSeedY });
|
||||
positions.set(edge.source, source);
|
||||
positions.set(edge.target, target);
|
||||
nextSeedY += V_SPACING * 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourcePosition && !targetPosition) {
|
||||
const target = reservePosition(occupied, { x: sourcePosition.x + H_SPACING, y: sourcePosition.y });
|
||||
positions.set(edge.target, target);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sourcePosition && targetPosition) {
|
||||
const source = reservePosition(occupied, { x: targetPosition.x - H_SPACING, y: targetPosition.y });
|
||||
positions.set(edge.source, source);
|
||||
}
|
||||
}
|
||||
|
||||
const unplaced = nodes.filter((node) => !positions.has(node.id));
|
||||
const columns = 4;
|
||||
let col = 0;
|
||||
let row = 0;
|
||||
|
||||
for (const node of unplaced) {
|
||||
const preferred = {
|
||||
x: col * H_SPACING,
|
||||
y: nextSeedY + row * V_SPACING,
|
||||
};
|
||||
positions.set(node.id, reservePosition(occupied, preferred));
|
||||
|
||||
col += 1;
|
||||
if (col >= columns) {
|
||||
col = 0;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function AtpX6Viewer({ graph }: AtpX6ViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const graphRef = useRef<X6Graph | null>(null);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
|
||||
const positions = useMemo(() => {
|
||||
if (!graph) {
|
||||
return new Map<string, NodePosition>();
|
||||
}
|
||||
return computeNodePositions(graph.nodes, graph.edges);
|
||||
}, [graph]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
let disposed = false;
|
||||
let createdGraph: X6Graph | null = null;
|
||||
|
||||
async function renderGraph() {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
setRenderError("");
|
||||
|
||||
if (!graph || graph.nodes.length === 0 || graph.edges.length === 0) {
|
||||
graphRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { Graph } = await import("@antv/x6");
|
||||
if (disposed || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new Graph({
|
||||
container,
|
||||
interacting: false,
|
||||
panning: true,
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
modifiers: ["ctrl", "meta"],
|
||||
minScale: 0.25,
|
||||
maxScale: 4,
|
||||
zoomAtMousePosition: true,
|
||||
},
|
||||
background: {
|
||||
color: "#fcfdff",
|
||||
},
|
||||
grid: false,
|
||||
});
|
||||
|
||||
createdGraph = instance;
|
||||
graphRef.current = instance;
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const position = positions.get(node.id) ?? { x: 0, y: 0 };
|
||||
const nodeId = `node_${node.id}`;
|
||||
|
||||
if (node.kind === "ground") {
|
||||
instance.addNode({
|
||||
id: nodeId,
|
||||
shape: "ellipse",
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: 86,
|
||||
height: 44,
|
||||
label: node.label,
|
||||
attrs: GROUND_NODE_STYLE,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
instance.addNode({
|
||||
id: nodeId,
|
||||
shape: "rect",
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: 140,
|
||||
height: 46,
|
||||
label: node.label,
|
||||
attrs: BUS_NODE_STYLE,
|
||||
});
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
instance.addEdge({
|
||||
id: edge.id,
|
||||
source: `node_${edge.source}`,
|
||||
target: `node_${edge.target}`,
|
||||
router: {
|
||||
name: "orth",
|
||||
},
|
||||
connector: {
|
||||
name: "rounded",
|
||||
args: {
|
||||
radius: 8,
|
||||
},
|
||||
},
|
||||
attrs: EDGE_STYLE,
|
||||
labels: [buildEdgeLabel(edge)],
|
||||
});
|
||||
}
|
||||
|
||||
instance.zoomToFit({ padding: 48, maxScale: 1 });
|
||||
instance.centerContent();
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "X6 渲染失败";
|
||||
setRenderError(detail);
|
||||
}
|
||||
}
|
||||
|
||||
void renderGraph();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (createdGraph) {
|
||||
createdGraph.dispose();
|
||||
}
|
||||
if (graphRef.current === createdGraph) {
|
||||
graphRef.current = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
}
|
||||
};
|
||||
}, [graph, positions]);
|
||||
|
||||
const handleFit = () => {
|
||||
const instance = graphRef.current;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.zoomToFit({ padding: 48, maxScale: 1 });
|
||||
instance.centerContent();
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const instance = graphRef.current;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = Math.min(instance.zoom() + 0.12, 4);
|
||||
instance.zoom(next, { absolute: true });
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
const instance = graphRef.current;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = Math.max(instance.zoom() - 0.12, 0.25);
|
||||
instance.zoom(next, { absolute: true });
|
||||
};
|
||||
|
||||
const hasRenderableGraph = !!graph && graph.nodes.length > 0 && graph.edges.length > 0;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} className="w-full">
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" onClick={handleFit} disabled={!hasRenderableGraph}>
|
||||
适配视图
|
||||
</Button>
|
||||
<Button size="small" onClick={handleZoomIn} disabled={!hasRenderableGraph}>
|
||||
放大
|
||||
</Button>
|
||||
<Button size="small" onClick={handleZoomOut} disabled={!hasRenderableGraph}>
|
||||
缩小
|
||||
</Button>
|
||||
{graph && (
|
||||
<Typography.Text type="secondary">
|
||||
节点 {graph.stats.node_count} / 元件 {graph.stats.element_count}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{renderError && <Alert type="error" showIcon message="渲染失败" description={renderError} />}
|
||||
|
||||
<div className="relative min-h-[560px] w-full overflow-hidden rounded border border-gray-200 bg-[#fcfdff]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-[560px] w-full"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, rgba(31, 35, 41, 0.08) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
/>
|
||||
{!hasRenderableGraph && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<Empty description="暂无可渲染图形,先完成 ATP 转换。" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user