From a530d3898cbb85c230f5dd5f966776f36360148d Mon Sep 17 00:00:00 2001 From: attacker Date: Wed, 8 Jul 2026 18:21:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(quality):=20Q20=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=B7=A8=E5=9E=8B=E7=BB=84=E4=BB=B6=E6=8B=86=E5=88=86=20?= =?UTF-8?q?=E2=80=94=20TopologyView=20596L=E2=86=92472L=20(-21%),=20?= =?UTF-8?q?=E6=8F=90=E5=8F=96=20types=20+=20TopologyCanvas=20+=20NodeDetai?= =?UTF-8?q?lDialog=20+=20EditNodeDialog=204=20=E4=B8=AA=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6,=20=E7=A7=BB=E9=99=A4=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=BA=A7=20eslint-disable=20+=20any=E2=86=92ClusterInfo=20?= =?UTF-8?q?=E5=85=B7=E4=BD=93=E6=8E=A5=E5=8F=A3=E7=B1=BB=E5=9E=8B,=20?= =?UTF-8?q?=E9=9B=B6=E9=80=BB=E8=BE=91=E5=8F=98=E6=9B=B4,=20ESLint=20error?= =?UTF-8?q?s=3D0/warnings=3D65/any=3D0,=20tsc=20-b=3D0,=20npm=20build?= =?UTF-8?q?=E2=9C=93,=2017/17=20=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_EVALUATION.md | 2 +- ROADMAP.md | 37 +++ .../components/kubernetes/TopologyView.tsx | 262 +++++------------- .../topology-view/EditNodeDialog.tsx | 65 +++++ .../topology-view/NodeDetailDialog.tsx | 93 +++++++ .../topology-view/TopologyCanvas.tsx | 94 +++++++ .../kubernetes/topology-view/types.ts | 33 +++ 7 files changed, 392 insertions(+), 194 deletions(-) create mode 100644 frontend/src/components/kubernetes/topology-view/EditNodeDialog.tsx create mode 100644 frontend/src/components/kubernetes/topology-view/NodeDetailDialog.tsx create mode 100644 frontend/src/components/kubernetes/topology-view/TopologyCanvas.tsx create mode 100644 frontend/src/components/kubernetes/topology-view/types.ts diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index de5cc2e..484f30c 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -129,7 +129,7 @@ | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | -| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q19 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + **GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级)**,累计提取 63 个独立组件文件** | +| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q20 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + **TopologyView 596L→472L (-21%,提取 TopologyCanvas/NodeDetailDialog/EditNodeDialog,移除文件级 eslint-disable + any→ClusterInfo 具体接口)**,累计提取 67 个独立组件文件** | ### 3.2 🟡 中优先级 diff --git a/ROADMAP.md b/ROADMAP.md index b3aad33..ea9b321 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -491,6 +491,43 @@ services/api.ts (1324L) | Q17 | 前端巨型组件拆分(DeploymentEditService 627L→394L,提取 types + ServiceCreateForm + IngressCreateForm + ServiceListItem + IngressListItem) | P0 | **done** | | Q18 | 前端巨型组件拆分(TraceManager 605L→331L,提取 types + trace-helpers + SpanTreeNode + TraceDetailDialog + SpanDetailDialog,修复 SpanTreeNode 内联定义反模式) | P0 | **done** | | Q19 | 前端巨型组件拆分(GrafanaDashboard 624L→78L,提取 types + datasource-toolbar + BatchDeployDialog + AgentTab + ExternalDatasourceTab,DRY 合并 Zabbix/Prometheus Tab,移除文件级 eslint-disable,类型安全升级 any→具体接口) | P0 | **done** | +| Q20 | 前端巨型组件拆分(TopologyView 596L→472L,提取 types + TopologyCanvas + NodeDetailDialog + EditNodeDialog,移除文件级 eslint-disable,any→具体接口类型) | P0 | **done** | + +--- + +## Q20: 前端巨型组件拆分 — TopologyView [done] + +**问题**: `TopologyView.tsx`(596 行)是 Q19 拆分后项目最大的 K8s 业务组件之一(排除 UI 库 sidebar/data-table),包含架构拓扑页面的全部逻辑:集群加载、拓扑数据构建(Promise.all 拉取 5 种资源 + buildTopology)、已保存布局合并、节点过滤、SVG 画布渲染(defs/marker/edges/nodes 绘制 + 拖拽 + 连线模式)、编辑模式操作(新增/编辑/删除节点 + 创建/删除连线)、保存/重置布局、节点详情弹窗(基本信息网格 + 关联连线列表)、新增/编辑节点弹窗。文件顶部有 `eslint-disable @typescript-eslint/no-explicit-any`,多处 `any` 类型(clusters/c/cluster/v/node.data)。单文件过大导致维护困难。 + +**已完成 (2026-07-08)**: + +将类型定义、SVG 画布渲染、节点详情弹窗、新增/编辑节点弹窗提取为 `topology-view/` 子目录下的独立文件: + +| 新文件 | 行数 | 职责 | +|--------|------|------| +| `types.ts` | 33 | ClusterInfo/EditFormState/DragOffset/SvgPoint 接口(替代 clusters: any[] 等 any 类型) | +| `TopologyCanvas.tsx` | 94 | SVG 拓扑画布渲染(defs pattern/marker + 层级行标签 + edges 连线 + nodes 节点圆+图标+名称),受控组件接收 18 个 props(svgRef/canvasW/canvasH/nodes/edges/filteredNodes/visibleIds/editMode/connectMode/connectSource/selectedNode/draggingId + 6 回调) | +| `NodeDetailDialog.tsx` | 93 | 节点详情弹窗(renderNodeDetailFields 基本信息 grid-cols-2 + 关联连线列表 →/← + 编辑模式操作按钮),接收 node/edges/nodes/editMode + 3 回调 | +| `EditNodeDialog.tsx` | 65 | 新增/编辑节点弹窗(名称 Input + 类型 Select + 备注 Textarea),接收 open/editingNode/editForm/setEditForm/onSave | + +主文件 `TopologyView.tsx` 从 **596 行降至 472 行**(-21%),仅保留:状态管理(clusters/topology/editMode/connectMode/editForm 等 20+ 个 useState)、数据加载(loadClusters/loadSavedLayout/loadTopology + Promise.all 5 资源拉取 + 布局合并)、拖拽处理(getSvgPoint/handleNodeMouseDown/handleMouseMove/handleMouseUp)、节点点击(区分普通/编辑/连线模式)、编辑操作(handleAddNode/handleEditNode/handleSaveNode/handleDeleteNode/handleDeleteEdge)、布局保存/重置、画布尺寸计算、filterToolbar、编辑模式工具栏、Canvas+NodeList 双栏布局、以及 3 个子组件编排。 + +**关键改进**: +- **移除文件级 `eslint-disable`**: `clusters: any[]` → `ClusterInfo[]`;`(cluster as any).code` → `cluster.code`(ClusterInfo 接口含 code 字段);`v: any` → `v: unknown` + toArray 返回 `unknown[]`;`(node.data as Record)` → `Record`。全局 ESLint any 保持 **0**。 +- **SVG 渲染提取**: 原 `renderTopology()` 内联函数(60 行)提取为 `TopologyCanvas` 独立组件,消除内联函数每次 render 重建的性能隐患。 + +**验收标准**: +- [x] TopologyView.tsx 从 596 行降至 **472 行**(-21%) +- [x] 4 个提取的文件全部 < 95 行(最大 TopologyCanvas 94 行) +- [x] `tsc -b` 零错误 +- [x] `npm run build` 零错误(✓ built in 17.89s) +- [x] ESLint errors 保持 **0**(topology-view/ 目录零 warnings) +- [x] ESLint warnings 保持 **65**(无新增) +- [x] 全局 ESLint any 保持 **0**(移除文件级 eslint-disable) +- [x] `go build` 零错误 +- [x] 17/17 集成回归测试通过(零回归) +- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) +- [x] 架构拓扑页功能不变(SVG 画布/节点拖拽/编辑模式/连线/保存布局/节点列表/详情弹窗全部正常) --- diff --git a/frontend/src/components/kubernetes/TopologyView.tsx b/frontend/src/components/kubernetes/TopologyView.tsx index 8d0b554..ebbe7f8 100644 --- a/frontend/src/components/kubernetes/TopologyView.tsx +++ b/frontend/src/components/kubernetes/TopologyView.tsx @@ -1,38 +1,34 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { - Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; -import { Textarea } from '@/components/ui/textarea'; -import { - Search, RefreshCw, Loader2, Cloud, Save, Plus, Trash2, Link2, Pencil, RotateCcw, + Search, RefreshCw, Loader2, Cloud, Save, Plus, Link2, Pencil, RotateCcw, } from 'lucide-react'; import { kubernetesApi } from '@/api'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { toast } from 'sonner'; import type { TopologyNode, TopologyEdge } from '@/types'; import { - buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, CUSTOM_NODE_TYPES, - isCustomNode, type TopologyLayoutData, + buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, isCustomNode, type TopologyLayoutData, } from './topology-helpers'; import { PageShell } from '@/components/layout/PageShell'; import { StatStrip } from '@/components/ui/stat-strip'; +import { TopologyCanvas } from './topology-view/TopologyCanvas'; +import { NodeDetailDialog } from './topology-view/NodeDetailDialog'; +import { EditNodeDialog } from './topology-view/EditNodeDialog'; +import type { ClusterInfo, EditFormState, DragOffset, SvgPoint } from './topology-view/types'; export function TopologyView() { const { t } = useTranslation(); const { clusterCode } = useParams<{ clusterCode?: string }>(); const _confirm = useConfirm(); - const [clusters, setClusters] = useState([]); + const [clusters, setClusters] = useState([]); const [selectedClusterId, setSelectedClusterId] = useState(''); const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] }); const [loading, setLoading] = useState(false); @@ -40,7 +36,7 @@ export function TopologyView() { const [searchQuery, setSearchQuery] = useState(''); const [viewType, setViewType] = useState('all'); const [draggingId, setDraggingId] = useState(null); - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const svgRef = useRef(null); // 编辑模式状态 // Logan.Li @@ -53,17 +49,17 @@ export function TopologyView() { // 编辑节点 Dialog const [editDialogOpen, setEditDialogOpen] = useState(false); const [editingNode, setEditingNode] = useState(null); - const [editForm, setEditForm] = useState<{ name: string; type: TopologyNode['type']; note: string }>({ name: '', type: 'database', note: '' }); + const [editForm, setEditForm] = useState({ name: '', type: 'database', note: '' }); const loadClusters = useCallback(async () => { try { - const res: Record = await kubernetesApi.getClusters(); - const list = res?.list || []; + const res = await kubernetesApi.getClusters(); + const list = ((res as { list?: ClusterInfo[] })?.list || []) as ClusterInfo[]; setClusters(list); // Auto-select from URL param (clusterCode) if provided, otherwise first cluster if (!selectedClusterId) { if (clusterCode) { - const matched = list.find(( c: any) => (c.code || String(c.id)) === clusterCode); + const matched = list.find((c) => (c.code || String(c.id)) === clusterCode); if (matched) setSelectedClusterId(String(matched.id)); } else if (list.length > 0) { setSelectedClusterId(String(list[0].id)); @@ -77,10 +73,10 @@ export function TopologyView() { // 加载已保存的布局 // Logan.Li const loadSavedLayout = useCallback(async (clusterId: string) => { try { - const res: Record = await kubernetesApi.getTopology(clusterId); - const layout = res as Record; + const res = await kubernetesApi.getTopology(clusterId) as Record; + const layout = res as Record; if (layout?.layout_data) { - const data = JSON.parse(layout.layout_data) as TopologyLayoutData; + const data = JSON.parse(layout.layout_data as string) as TopologyLayoutData; setSavedLayout(data); if (data.customNodes?.length > 0) { const maxId = Math.max(...data.customNodes.map((n) => { @@ -99,16 +95,20 @@ export function TopologyView() { if (!selectedClusterId) return; setLoading(true); try { - const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || [])); - const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {}; + const toArray = (v: unknown): unknown[] => { + if (Array.isArray(v)) return v; + const r = v as Record; + return (r?.list || []) as unknown[]; + }; + const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {} as ClusterInfo; // 统一使用 Kuboard code 路由获取资源 (ROADMAP A1) - const clusterCode = (cluster as any).code || selectedClusterId; + const code = cluster.code || selectedClusterId; const [nodesRes, svcRes, depRes, ingRes, podRes] = await Promise.all([ - kubernetesApi.getNodes(clusterCode).catch(() => []), - kubernetesApi.getServices(clusterCode).catch(() => []), - kubernetesApi.getDeployments(clusterCode).catch(() => []), - kubernetesApi.getIngresses(clusterCode).catch(() => []), - kubernetesApi.getPods(clusterCode).catch(() => []), + kubernetesApi.getNodes(code).catch(() => []), + kubernetesApi.getServices(code).catch(() => []), + kubernetesApi.getDeployments(code).catch(() => []), + kubernetesApi.getIngresses(code).catch(() => []), + kubernetesApi.getPods(code).catch(() => []), ]); const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes), toArray(podRes)); @@ -141,7 +141,7 @@ export function TopologyView() { const visibleIds = new Set(filteredNodes.map((n) => n.id)); // --- 拖拽 --- - const getSvgPoint = (e: React.MouseEvent): { x: number; y: number } => { + const getSvgPoint = (e: React.MouseEvent): SvgPoint => { const svg = svgRef.current; if (!svg) return { x: 0, y: 0 }; const pt = svg.createSVGPoint(); @@ -204,7 +204,8 @@ export function TopologyView() { const handleEditNode = (node: TopologyNode) => { setEditingNode(node); - setEditForm({ name: node.name, type: node.type, note: (node.data as Record)?.note || '' }); + const note = (node.data as Record)?.note as string || ''; + setEditForm({ name: node.name, type: node.type, note }); setEditDialogOpen(true); }; @@ -290,87 +291,6 @@ export function TopologyView() { const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length; - // --- 渲染 SVG 拓扑 --- - const renderTopology = () => { - if (topology.nodes.length === 0) return null; - const ys = [60, 180, 320, 480, 640]; - const labels = [t('k8s.tab.overview'), 'Ingress', 'Service', 'Deployment', 'Node']; - return ( - - - - - - - - - - - {labels.map((label, i) => ( - {label} - ))} - {topology.edges.map((edge) => { - const source = topology.nodes.find((n) => n.id === edge.source); - const target = topology.nodes.find((n) => n.id === edge.target); - if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null; - const isCustom = edge.id.startsWith('custom-edge'); - return ( - { if (editMode && isCustom) handleDeleteEdge(edge); }}> - - {editMode && isCustom && ( - ✕ {t('k8s.topology.deleteText')} - )} - - ); - })} - {filteredNodes.map((node) => { - const isCustom = isCustomNode(node.id); - const isConnectSource = connectSource === node.id; - return ( - handleNodeMouseDown(e, node)} - onClick={() => handleNodeClick(node)} - onDoubleClick={() => { if (editMode) handleEditNode(node); }}> - - -
{getNodeIcon(node.type)}
-
- - {node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name} - -
- ); - })} -
- ); - }; - - const renderNodeDetailFields = (node: TopologyNode) => { - const data = node.data || {}; - const entries = Object.entries(data).filter(([k]) => k !== 'kind'); - if (entries.length === 0) return null; - return ( -
- {entries.map(([key, val]) => ( -
-

{key}

-

{val === null || val === undefined ? '—' : String(val)}

-
- ))} -
- ); - }; - const selectedClusterName = clusters.find((c) => String(c.id) === selectedClusterId)?.name; const filterToolbar = (
@@ -474,7 +394,28 @@ export function TopologyView() {

{selectedClusterId ? t('k8s.topology.noDataWithCluster') : t('k8s.topology.noDataSelectCluster')}

- ) : renderTopology()} + ) : ( + { if (editMode) handleEditNode(node); }} + onEdgeClick={handleDeleteEdge} + /> + )} @@ -507,90 +448,25 @@ export function TopologyView() { {/* Node Detail Dialog */} - setSelectedNode(null)}> - - - -
{selectedNode && getNodeIcon(selectedNode.type)}
- {selectedNode?.name} - {selectedNode && isCustomNode(selectedNode.id) && {t('k8s.topology.custom2')}} -
- {t('common.type')}: {NODE_TYPE_LABELS[selectedNode?.type || ''] ? t(NODE_TYPE_LABELS[selectedNode?.type || '']) : selectedNode?.type} -
- {selectedNode && ( -
- {renderNodeDetailFields(selectedNode)} -
-

{t('k8s.topology.relatedConnections')}

-
- {topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => { - const isSource = edge.source === selectedNode.id; - const related = topology.nodes.find((n) => n.id === (isSource ? edge.target : edge.source)); - if (!related) return null; - return ( -
- {isSource ? '→' : '←'} -
{getNodeIcon(related.type, 'h-4 w-4')}
- {related.name} - {edge.label} -
- ); - })} - {topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 &&

{t('k8s.topology.noConnections')}

} -
-
- {/* 编辑模式操作按钮 */} - {editMode && selectedNode && ( -
- - {isCustomNode(selectedNode.id) && ( - - )} -
- )} -
- )} -
-
+ setSelectedNode(null)} + onEditNode={handleEditNode} + onDeleteNode={handleDeleteNode} + /> {/* Edit/Add Node Dialog */} - - - - {editingNode ? t('k8s.topology.editNodeTitle') : t('k8s.topology.addNodeTitle')} - {editingNode ? t('k8s.topology.editNodeDesc') : t('k8s.topology.addNodeDesc')} - -
-
- - setEditForm({ ...editForm, name: e.target.value })} - placeholder={t('k8s.topology.namePlaceholder')} className="dg-input" /> -
-
- - -
-
- -