diff --git a/docs/changelog/index.md b/docs/changelog/index.md index bf86c0d0..606454df 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -33,6 +33,7 @@ sidebar: false ### 修复 +- WAF 规则编辑器支持通过按钮或键盘删除普通节点和连线,节点属性栏改为选中节点后按需显示,并修复拖动节点时因 React Flow 初始化状态丢失导致的画面闪烁与 error 015。 - WAF 规则编辑器新增启用/停用控制,并移除已废弃的规则组侧站点绑定入口;站点规则顺序统一在反代路由详情中管理。 - 修复 Agent 心跳无法从活动 WAF 运行图发现 IP 组引用的问题,并对发布与同步的完整 IP 组快照增加 20 MiB 聚合容量保护。 - 修复 Agent 增量同步长期保留已取消引用的 WAF IP 组、最终阻塞合法热更新的问题;配置同步现按活动引用集合权威收敛,实时广播仅更新本地现存组。 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index 9bb979ae..4020b770 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -85,9 +85,10 @@ Agent 必须先原子替换 IP 组 JSON,最后原子更新 checksum,使 Work React Flow 编辑页采用全宽画布和固定右侧属性栏: * 顶部提供返回、规则名称、启用状态、校验状态和保存操作。 -* 画布支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力。 +* 画布支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力;节点拖动由 React Flow 本地受控状态实时处理,拖动结束后才把坐标写回编辑图。 * “添加处理单元”提供 IP 匹配、地域匹配、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。 -* 选中节点后在右侧属性栏编辑配置,画布与上下文始终可见。 +* 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。 +* 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。 * 离开存在未保存变更的页面前必须提示;保存冲突和 Server 校验错误应定位到相关节点或边。 WAF 列表展示规则名称、启用状态、节点数量、应用路由数量和更新时间。新建规则的对话框只有名称字段,成功后立即导航到编排页面。 diff --git a/docs/guide/waf-usage.md b/docs/guide/waf-usage.md index 3e7ea253..1378ecc6 100644 --- a/docs/guide/waf-usage.md +++ b/docs/guide/waf-usage.md @@ -13,6 +13,10 @@ OpenFlare WAF 使用可视化有向无环图编排规则。新建规则时只填 服务端会拒绝循环、悬空出口、不可达节点、重复端口连接和无效配置。保存时携带页面加载得到的 `revision`;发生 409 冲突时应重新加载,避免覆盖他人修改。 +选中普通节点或连线后,可点击画布右上角的删除按钮,或按 Delete/Backspace 删除。删除节点会同时删除关联连线;唯一的“开始”和“通过”节点不可删除。拖动节点只在松开时记录最终坐标,不会在移动过程中反复重建画布状态。 + +右侧节点属性栏默认隐藏,点击节点后显示;点击连线或画布空白区域后自动收起。 + ## 绑定与生效 启用的全局规则固定最先执行;路由绑定的自定义规则严格按列表顺序执行。调整顺序后需要发布配置版本,规则拓扑才会随 OpenResty reload 生效。 diff --git a/docs/plan/20260713-waf-orchestration.md b/docs/plan/20260713-waf-orchestration.md index 04a38e4a..7bdb2959 100644 --- a/docs/plan/20260713-waf-orchestration.md +++ b/docs/plan/20260713-waf-orchestration.md @@ -10,9 +10,9 @@ ## 实现状态(2026-07-13) -Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。 +Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。 -当前工作区已完成 `go test ./...`、前端全量 Vitest(54 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。 +当前工作区已完成 `go test ./...`、前端全量 Vitest(56 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。 ## Global Constraints diff --git a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts index d42b2559..16e8ea71 100644 --- a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts +++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.test.ts @@ -16,6 +16,19 @@ describe('React Flow persistence filtering', () => { expect(isPersistentNodeChange({type: 'remove', id: 'n'})).toBe(true); expect(isPersistentEdgeChange({type: 'remove', id: 'e'})).toBe(true); }); + + it('forwards transient node changes to React Flow without persisting them', () => { + const changes = [ + {type: 'dimensions' as const, id: 'match', dimensions: {width: 176, height: 64}}, + {type: 'select' as const, id: 'match', selected: true}, + {type: 'position' as const, id: 'match', position: {x: 24, y: 48}, dragging: true}, + ]; + + const nodes: WAFRuleGraph['nodes'] = [ + {id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}}, + ]; + expect(acceptedNodeChanges(nodes, changes)).toEqual({changes, persistent: false}); + }); }); describe('editor safety constraints', () => { diff --git a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts index 0f3b0c2d..45c62c17 100644 --- a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts +++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts @@ -1,11 +1,11 @@ -import type {EdgeChange, NodeChange} from '@xyflow/react'; +import type {EdgeChange, Node, NodeChange} from '@xyflow/react'; import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare'; import {wouldCreateCycle} from './graph-validation'; export type GraphErrorTarget = {kind: 'node' | 'edge'; id: string}; -export function isPersistentNodeChange(change: NodeChange): boolean { +export function isPersistentNodeChange(change: NodeChange): boolean { return change.type === 'remove' || change.type === 'add' || change.type === 'replace' || (change.type === 'position' && change.dragging === false && Boolean(change.position)); } @@ -17,8 +17,8 @@ export function filterRemovableNodeIds(nodes: WAFRuleNode[], ids: string[]): str return ids.filter((id) => !['start', 'allow'].includes(nodes.find((node) => node.id === id)?.type ?? '')); } -export function acceptedNodeChanges(nodes: WAFRuleNode[], changes: NodeChange[]): {changes: NodeChange[]; persistent: boolean} { - const accepted = changes.filter((change) => change.type === 'position' || (change.type === 'remove' && filterRemovableNodeIds(nodes, [change.id]).length === 1)); +export function acceptedNodeChanges(nodes: WAFRuleNode[], changes: NodeChange[]): {changes: NodeChange[]; persistent: boolean} { + const accepted = changes.filter((change) => change.type !== 'remove' || filterRemovableNodeIds(nodes, [change.id]).length === 1); return {changes: accepted, persistent: accepted.some(isPersistentNodeChange)}; } diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts b/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts index a4a54cbb..3a7265fd 100644 --- a/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts +++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.test.ts @@ -2,7 +2,7 @@ import {describe, expect, it} from 'vitest'; import type {WAFRuleGraph} from '@/lib/services/openflare'; -import {removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation'; +import {removeEdgeFromGraph, removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation'; const validGraph = (): WAFRuleGraph => ({ schema_version: 1, @@ -110,6 +110,15 @@ it('removes incident edges when deleting a node', () => { expect(next.edges).toEqual([]); }); +it('removes a selected connection without changing nodes', () => { + const graph = validGraph(); + const edgeId = graph.edges[0].id; + const next = removeEdgeFromGraph(graph, edgeId); + expect(next.nodes).toEqual(graph.nodes); + expect(next.edges).toHaveLength(graph.edges.length - 1); + expect(next.edges.some((edge) => edge.id === edgeId)).toBe(false); +}); + it('detects whether a new connection creates a cycle', () => { expect(wouldCreateCycle(validGraph(), 'allow', 'start')).toBe(true); expect(wouldCreateCycle(validGraph(), 'start', 'block')).toBe(false); diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts index cd4fcad8..eca18df3 100644 --- a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts +++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts @@ -144,3 +144,7 @@ export function wouldCreateCycle(graph: WAFRuleGraph, source: string, target: st export function removeNodeFromGraph(graph: WAFRuleGraph, nodeId: string): WAFRuleGraph { return {...graph, nodes: graph.nodes.filter((node) => node.id !== nodeId), edges: graph.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)}; } + +export function removeEdgeFromGraph(graph: WAFRuleGraph, edgeId: string): WAFRuleGraph { + return {...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId)}; +} diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx index 7c6f7a8f..b7e36edd 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -15,8 +15,8 @@ import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare'; const countries = ['CN', 'US', 'JP', 'SG', 'DE', 'FR', 'GB', 'CA', 'AU', 'BR', 'IN', 'KR'].map((value) => ({value, label: value})); const regions = ['CN-BJ', 'CN-SH', 'CN-GD', 'CN-ZJ', 'US-CA', 'US-NY', 'US-TX', 'JP-13', 'DE-BE', 'GB-ENG'].map((value) => ({value, label: value})); -export function NodeProperties({node, ipGroups, onChange}: {node?: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) { - return ; +export function NodeProperties({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) { + return ; } function PropertyFields({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) { diff --git a/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx b/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx index 161ae35f..229ca3d8 100644 --- a/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx @@ -1,49 +1,124 @@ 'use client'; -import {useCallback, useEffect, useMemo, useRef} from 'react'; -import {addEdge, Background, Controls, MiniMap, ReactFlow, type Connection, type Edge, type Node, type NodeChange, applyNodeChanges, type EdgeChange, applyEdgeChanges, type ReactFlowInstance} from '@xyflow/react'; +import {useCallback, useEffect, useRef} from 'react'; +import { + addEdge, + applyEdgeChanges, + Background, + Controls, + MiniMap, + ReactFlow, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type EdgeChange, + type Node, + type NodeChange, + type ReactFlowInstance, +} from '@xyflow/react'; +import {Trash2} from 'lucide-react'; import '@xyflow/react/dist/style.css'; +import {Button} from '@/components/ui/button'; import type {WAFRuleEdge, WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare'; -import {type GraphIssue, removeNodeFromGraph} from './graph-validation'; -import {acceptedNodeChanges, filterRemovableNodeIds, type GraphErrorTarget, isConnectionAllowed, isPersistentEdgeChange} from './editor-behavior'; +import { + acceptedNodeChanges, + filterRemovableNodeIds, + type GraphErrorTarget, + isConnectionAllowed, + isPersistentEdgeChange, +} from './editor-behavior'; +import {type GraphIssue, removeEdgeFromGraph, removeNodeFromGraph} from './graph-validation'; import {NodeLibrary} from './node-library'; import {RuleNode, type RuleFlowNodeData} from './rule-node'; const nodeTypes = {rule: RuleNode}; -export function RuleFlowCanvas({graph, issues, selectedId, selectedEdgeId, focusTarget, onGraphChange, onSelect, onSelectEdge}: {graph: WAFRuleGraph; issues: GraphIssue[]; selectedId?: string; selectedEdgeId?: string; focusTarget?: GraphErrorTarget; onGraphChange: (graph: WAFRuleGraph, persistent?: boolean) => void; onSelect: (id?: string) => void; onSelectEdge: (id?: string) => void}) { - const instance = useRef, Edge> | null>(null); - const nodes = useMemo[]>(() => graph.nodes.map((rule) => ({id: rule.id, type: 'rule', position: rule.position, selected: rule.id === selectedId, data: {rule, issues: issues.filter((issue) => issue.nodeId === rule.id).length}})), [graph.nodes, issues, selectedId]); - const edges = useMemo(() => graph.edges.map((edge) => ({id: edge.id, source: edge.source, sourceHandle: edge.source_handle, target: edge.target, selected: edge.id === selectedEdgeId, animated: selectedId === edge.source || selectedId === edge.target})), [graph.edges, selectedEdgeId, selectedId]); +type FlowNode = Node; +type FlowEdge = Edge; + +interface RuleFlowCanvasProps { + graph: WAFRuleGraph; + issues: GraphIssue[]; + selectedId?: string; + selectedEdgeId?: string; + focusTarget?: GraphErrorTarget; + onGraphChange: (graph: WAFRuleGraph, persistent?: boolean) => void; + onSelect: (id?: string) => void; + onSelectEdge: (id?: string) => void; +} + +export function RuleFlowCanvas({ + graph, + issues, + selectedId, + selectedEdgeId, + focusTarget, + onGraphChange, + onSelect, + onSelectEdge, +}: RuleFlowCanvasProps) { + const instance = useRef | null>(null); + const [nodes, setNodes, applyFlowNodeChanges] = useNodesState(buildFlowNodes(graph, issues, selectedId)); + const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState(buildFlowEdges(graph, selectedId, selectedEdgeId)); + + useEffect(() => { + setNodes((current) => buildFlowNodes(graph, issues, selectedId, current)); + }, [graph, issues, selectedId, setNodes]); + + useEffect(() => { + setEdges((current) => buildFlowEdges(graph, selectedId, selectedEdgeId, current)); + }, [graph, selectedEdgeId, selectedId, setEdges]); useEffect(() => { if (!focusTarget || !instance.current) return; - if (focusTarget.kind === 'node') void instance.current.fitView({nodes: [{id: focusTarget.id}], duration: 350, maxZoom: 1.4}); - else { - const edge = graph.edges.find((item) => item.id === focusTarget.id); - if (edge) void instance.current.fitView({nodes: [{id: edge.source}, {id: edge.target}], duration: 350, maxZoom: 1.4}); + if (focusTarget.kind === 'node') { + void instance.current.fitView({nodes: [{id: focusTarget.id}], duration: 350, maxZoom: 1.4}); + return; } + const edge = graph.edges.find((item) => item.id === focusTarget.id); + if (edge) void instance.current.fitView({nodes: [{id: edge.source}, {id: edge.target}], duration: 350, maxZoom: 1.4}); }, [focusTarget, graph.edges]); - const onNodesChange = useCallback((changes: NodeChange[]) => { + const onNodesChange = useCallback((changes: NodeChange[]) => { const accepted = acceptedNodeChanges(graph.nodes, changes); if (accepted.changes.length === 0) return; - const removals = filterRemovableNodeIds(graph.nodes, accepted.changes.filter((change) => change.type === 'remove').map((change) => change.id)); + + applyFlowNodeChanges(accepted.changes); + if (!accepted.persistent) return; + + const removals = filterRemovableNodeIds( + graph.nodes, + accepted.changes.filter((change) => change.type === 'remove').map((change) => change.id), + ); let next = graph; for (const id of removals) next = removeNodeFromGraph(next, id); - const positioned = applyNodeChanges(accepted.changes.filter((change) => change.type !== 'remove'), nodes); - const positions = new Map(positioned.map((node) => [node.id, node.position])); - onGraphChange({...next, nodes: next.nodes.map((node) => ({...node, position: positions.get(node.id) ?? node.position}))}, accepted.persistent); - }, [graph, nodes, onGraphChange]); - const onEdgesChange = useCallback((changes: EdgeChange[]) => { + const positions = new Map(); + for (const change of accepted.changes) { + if (change.type === 'position' && change.dragging === false && change.position) { + positions.set(change.id, change.position); + } + } + next = {...next, nodes: next.nodes.map((node) => ({...node, position: positions.get(node.id) ?? node.position}))}; + onGraphChange(next); + + if (selectedId && removals.includes(selectedId)) onSelect(undefined); + }, [applyFlowNodeChanges, graph, onGraphChange, onSelect, selectedId]); + + const onEdgesChange = useCallback((changes: EdgeChange[]) => { + applyFlowEdgeChanges(changes); const persistent = changes.filter(isPersistentEdgeChange); if (persistent.length === 0) return; + const next = applyEdgeChanges(persistent, edges); onGraphChange({...graph, edges: next.map(toRuleEdge)}); - }, [edges, graph, onGraphChange]); + if (selectedEdgeId && persistent.some((change) => change.type === 'remove' && change.id === selectedEdgeId)) { + onSelectEdge(undefined); + } + }, [applyFlowEdgeChanges, edges, graph, onGraphChange, onSelectEdge, selectedEdgeId]); const isValidConnection = useCallback((connection: Edge | Connection) => { return isConnectionAllowed(graph, connection); @@ -52,17 +127,109 @@ export function RuleFlowCanvas({graph, issues, selectedId, selectedEdgeId, focus const onConnect = useCallback((connection: Connection) => { if (!isValidConnection(connection)) return; const next = addEdge({...connection, id: `${connection.source}-${connection.sourceHandle}-${connection.target}`}, edges); + setEdges(next); onGraphChange({...graph, edges: next.map(toRuleEdge)}); - }, [edges, graph, isValidConnection, onGraphChange]); + }, [edges, graph, isValidConnection, onGraphChange, setEdges]); const addNode = useCallback((type: 'ip_match' | 'geo_match' | 'pow' | 'block') => { const id = `${type}-${crypto.randomUUID().slice(0, 8)}`; - const config = type === 'ip_match' ? {ips: [], cidrs: [], ip_group_ids: []} : type === 'geo_match' ? {countries: [], regions: []} : type === 'pow' ? {algorithm: 'fast' as const, difficulty: 4, session_ttl: 3600, challenge_ttl: 300} : {status_code: 403, response_body: ''}; + const config = type === 'ip_match' + ? {ips: [], cidrs: [], ip_group_ids: []} + : type === 'geo_match' + ? {countries: [], regions: []} + : type === 'pow' + ? {algorithm: 'fast' as const, difficulty: 4, session_ttl: 3600, challenge_ttl: 300} + : {status_code: 403, response_body: ''}; onGraphChange({...graph, nodes: [...graph.nodes, {id, type, position: {x: 240, y: 140 + graph.nodes.length * 24}, config} as WAFRuleNode]}); + onSelectEdge(undefined); onSelect(id); - }, [graph, onGraphChange, onSelect]); + }, [graph, onGraphChange, onSelect, onSelectEdge]); - return
{ instance.current = value; }} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} isValidConnection={isValidConnection} onNodeClick={(_, node) => { onSelectEdge(undefined); onSelect(node.id); }} onEdgeClick={(_, edge) => { onSelect(undefined); onSelectEdge(edge.id); }} onPaneClick={() => { onSelect(undefined); onSelectEdge(undefined); }} fitView deleteKeyCode={['Backspace', 'Delete']}>
; + const selectedNode = graph.nodes.find((node) => node.id === selectedId); + const canDeleteNode = Boolean(selectedNode && !['start', 'allow'].includes(selectedNode.type)); + const canDeleteEdge = Boolean(selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId)); + const canDelete = canDeleteNode || canDeleteEdge; + const deleteLabel = canDeleteEdge ? '删除连线' : canDeleteNode ? '删除节点' : selectedNode ? '系统节点不可删除' : '请选择可删除项'; + + const deleteSelection = useCallback(() => { + if (canDeleteEdge && selectedEdgeId) { + setEdges((current) => current.filter((edge) => edge.id !== selectedEdgeId)); + onGraphChange(removeEdgeFromGraph(graph, selectedEdgeId)); + onSelectEdge(undefined); + return; + } + if (canDeleteNode && selectedId) { + setNodes((current) => current.filter((node) => node.id !== selectedId)); + setEdges((current) => current.filter((edge) => edge.source !== selectedId && edge.target !== selectedId)); + onGraphChange(removeNodeFromGraph(graph, selectedId)); + onSelect(undefined); + } + }, [canDeleteEdge, canDeleteNode, graph, onGraphChange, onSelect, onSelectEdge, selectedEdgeId, selectedId, setEdges, setNodes]); + + return ( +
+
+ +
+
+ +
+ { instance.current = value; }} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={onConnect} + isValidConnection={isValidConnection} + onBeforeDelete={async ({nodes: deletingNodes}) => !deletingNodes.some((node) => ['start', 'allow'].includes(node.data.rule.type))} + onNodeClick={(_, node) => { onSelectEdge(undefined); onSelect(node.id); }} + onEdgeClick={(_, edge) => { onSelect(undefined); onSelectEdge(edge.id); }} + onPaneClick={() => { onSelect(undefined); onSelectEdge(undefined); }} + fitView + deleteKeyCode={['Backspace', 'Delete']} + > + + + + +
+ ); } -function toRuleEdge(edge: Edge): WAFRuleEdge { return {id: edge.id, source: edge.source, source_handle: edge.sourceHandle ?? '', target: edge.target}; } +function buildFlowNodes(graph: WAFRuleGraph, issues: GraphIssue[], selectedId?: string, current: FlowNode[] = []): FlowNode[] { + const existing = new Map(current.map((node) => [node.id, node])); + const issueCounts = new Map(); + for (const issue of issues) { + if (issue.nodeId) issueCounts.set(issue.nodeId, (issueCounts.get(issue.nodeId) ?? 0) + 1); + } + return graph.nodes.map((rule) => ({ + ...existing.get(rule.id), + id: rule.id, + type: 'rule', + position: rule.position, + selected: rule.id === selectedId, + data: {rule, issues: issueCounts.get(rule.id) ?? 0}, + })); +} + +function buildFlowEdges(graph: WAFRuleGraph, selectedId?: string, selectedEdgeId?: string, current: FlowEdge[] = []): FlowEdge[] { + const existing = new Map(current.map((edge) => [edge.id, edge])); + return graph.edges.map((edge) => ({ + ...existing.get(edge.id), + id: edge.id, + source: edge.source, + sourceHandle: edge.source_handle, + target: edge.target, + selected: edge.id === selectedEdgeId, + animated: selectedId === edge.source || selectedId === edge.target, + })); +} + +function toRuleEdge(edge: Edge): WAFRuleEdge { + return {id: edge.id, source: edge.source, source_handle: edge.sourceHandle ?? '', target: edge.target}; +} diff --git a/frontend/app/(main)/waf/rules/editor/page.tsx b/frontend/app/(main)/waf/rules/editor/page.tsx index 8c982ae1..c313bc04 100644 --- a/frontend/app/(main)/waf/rules/editor/page.tsx +++ b/frontend/app/(main)/waf/rules/editor/page.tsx @@ -99,7 +99,12 @@ function EditorContent() {
-

{ruleQuery.data.name}

{ruleQuery.data.enabled ? '已启用' : '已停用'}{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}{dirty && 未保存}
+
+

{ruleQuery.data.name}

+ {ruleQuery.data.enabled ? '已启用' : '已停用'} + {issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`} + {dirty && 未保存} +
0 || saveMutation.isPending} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? '保存中...' : '保存'}
-
+
+ {selected && } +
); } diff --git a/frontend/next.config.ts b/frontend/next.config.ts index af7345cb..890f7065 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -7,6 +7,7 @@ const nextConfig: NextConfig = { // Prevent 308 redirects on /api/* trailing slashes; dev rewrites proxy legacy APIs as-is. skipTrailingSlashRedirect: true, experimental: { + turbopackFileSystemCacheForBuild: true, }, ...(isExport ? { output: 'export' } : { async redirects() {