("form")?.requestSubmit()
+ return event.stopPropagation()} style={{position: "absolute", inset: 18, zIndex: 20, display: "flex", flexDirection: "column", overflow: "hidden", background: "rgba(255,255,255,.98)", border: "1px solid #d9d9d9", borderRadius: 12, boxShadow: "0 14px 42px rgba(0,0,0,.18)"}}>
+
{title}
+
+
+
+}
+function Connection_Overlay({initial_feed_id, feeds, sources, source_for_feed, on_submit, on_close}: {initial_feed_id?: string, feeds: Topology_Node[], sources: Topology_Node[], source_for_feed(feed_id: string): string, on_submit(feed: Topology_Node, source_id: string): void, on_close(): void}) {
+ const [feed_id, set_feed_id] = useState(initial_feed_id ?? "")
+ const [source_id, set_source_id] = useState(initial_feed_id ? source_for_feed(initial_feed_id) : "")
+ const feed = feeds.find(item => item.id === feed_id)
+ const current_source = feed_id ? source_for_feed(feed_id) : ""
+ const select_feed = (value: string) => {
+ set_feed_id(value)
+ set_source_id(source_for_feed(value))
+ }
+ return event.stopPropagation()} style={{position: "absolute", top: 24, left: "50%", zIndex: 21, width: 430, maxWidth: "calc(100% - 48px)", transform: "translateX(-50%)", background: "#fff", border: "1px solid #d9d9d9", borderRadius: 12, boxShadow: "0 14px 42px rgba(0,0,0,.18)", padding: 18}}>
+ {initial_feed_id ? `编辑连接:${feed?.label ?? ""}` : "新建连接"}
+
+
+
}
-
export function DataTopology({schema}: {schema: Data_Topology_Schema}) {
- const container = useRef(null)
- const graph_ref = useRef(null)
- const [data, set_data] = useState(null)
- const [selected_id, set_selected_id] = useState("")
- const [editor_kind, set_editor_kind] = useState("")
+ const shell = useRef(null)
+ const flow_ref = useRef | null>(null)
+ const working_view_ref = useRef(null)
+ const restoring_ref = useRef(true)
+ const replace_flow_ref = useRef(true)
+ const viewport_interaction_ref = useRef(false)
+ const dirty_ref = useRef(false)
+ const server_signature_ref = useRef("")
+ const runtime_signature_ref = useRef("")
+ const status_expansion_ref = useRef(new Set())
+ const temporary_id = useRef(-1)
+ const [baseline, set_baseline] = useState(null)
+ const [draft, set_draft] = useState(null)
+ const [flow_nodes, set_flow_nodes] = useState([])
+ const [flow_edges, set_flow_edges] = useState([])
+ const [flow_ready, set_flow_ready] = useState(false)
+ const [view_dirty, set_view_dirty] = useState(false)
+ const [saving, set_saving] = useState(false)
+ const [editor_state, set_editor_state] = useState(null)
+ const [connection_state, set_connection_state] = useState(null)
+ const [context_menu, set_context_menu] = useState(null)
const [error, set_error] = useState("")
- const [view_error, set_view_error] = useState("")
-
- const load = useCallback(async () => {
+ const content_dirty = useMemo(() => draft_signature(draft) !== draft_signature(baseline), [baseline, draft])
+ const dirty = content_dirty || view_dirty
+ dirty_ref.current = dirty
+ const finish_restoring = useCallback(() => {
+ window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
+ restoring_ref.current = false
+ }))
+ }, [])
+ const restore_viewport = useCallback((view: Topology_View) => {
+ const instance = flow_ref.current
+ if(!instance)
+ return
+ restoring_ref.current = true
+ void instance.setViewport({x: view.pan_x, y: view.pan_y, zoom: view.zoom}, {duration: 0}).then(() => {
+ finish_restoring()
+ })
+ }, [finish_restoring])
+ const load = useCallback(async (discard: boolean) => {
try {
- const response = await axios.get>(schema.data_api)
- if (response.data.data.protocol !== "ecap.data-topology" || response.data.data.protocol_version !== 1)
+ const response = await axios.get>(schema.data_api, {timeout: 8000})
+ const next = response.data.data
+ if(next.protocol !== "ecap.data-topology" || next.protocol_version !== 1)
throw new Error("数据拓扑协议或版本不受支持")
- if (response.data.data.view.protocol !== "ecap.data-topology-view" || response.data.data.view.protocol_version !== 1)
+ if(next.view.protocol !== "ecap.data-topology-view" || next.view.protocol_version !== 1)
throw new Error("数据拓扑视图协议或版本不受支持")
- set_data(response.data.data)
+ const next_server_signature = draft_signature(next)
+ const next_runtime_signature = runtime_signature(next)
+ if(discard || !dirty_ref.current && next_server_signature !== server_signature_ref.current) {
+ server_signature_ref.current = next_server_signature
+ runtime_signature_ref.current = next_runtime_signature
+ working_view_ref.current = clone_data(next.view)
+ replace_flow_ref.current = true
+ set_baseline(clone_data(next))
+ set_draft(clone_data(next))
+ set_view_dirty(false)
+ if(discard)
+ restore_viewport(next.view)
+ } else if(next_runtime_signature !== runtime_signature_ref.current) {
+ runtime_signature_ref.current = next_runtime_signature
+ set_baseline(current => current ? merge_runtime(current, next) : clone_data(next))
+ set_draft(current => current ? merge_runtime(current, next) : clone_data(next))
+ }
set_error("")
- } catch (reason) {
+ } catch(reason) {
set_error(reason instanceof Error ? reason.message : String(reason))
}
- }, [schema.data_api])
-
+ }, [restore_viewport, schema.data_api])
useEffect(() => {
- void load()
- window.addEventListener(topology_changed_event, load)
- return () => window.removeEventListener(topology_changed_event, load)
- }, [load])
-
+ void load(true)
+ const timer = window.setInterval(() => void load(false), Math.max(500, schema.graph.poll_interval_ms))
+ return () => window.clearInterval(timer)
+ }, [load, schema.graph.poll_interval_ms])
const kind_by_name = useMemo(() => new Map(schema.node_kinds.map(kind => [kind.kind, kind])), [schema.node_kinds])
- const selected = data?.nodes.find(node => node.id === selected_id)
- const selected_kind = selected && kind_by_name.get(selected.kind)
- const editor = schema.editors.find(item => item.kind === editor_kind)
-
+ const editor_by_kind = useMemo(() => new Map(schema.editors.map(editor => [editor.kind, editor])), [schema.editors])
+ const sources = useMemo(() => draft?.nodes.filter(node => node.kind === "data_source") ?? [], [draft])
+ const feeds = useMemo(() => draft?.nodes.filter(node => node.kind === "data_feed") ?? [], [draft])
+ const source_for_feed = useCallback((feed_id: string) => draft?.edges.find(edge => edge.kind === "configuration" && edge.target === feed_id)?.source ?? "", [draft])
+ const update_draft = useCallback((update: (current: Topology_Data) => Topology_Data) => {
+ set_draft(current => {
+ if(!current)
+ return current
+ const view = flow_ref.current ? capture_flow_view(flow_ref.current, current, schema.graph.layout_revision) : working_view_ref.current ?? current.view
+ working_view_ref.current = view
+ return update({...current, view})
+ })
+ }, [schema.graph.layout_revision])
+ const open_create = useCallback((kind: string) => {
+ set_context_menu(null)
+ set_connection_state(null)
+ set_editor_state({kind, mode: "create"})
+ }, [])
+ const open_edit = useCallback((node: Topology_Node) => {
+ set_context_menu(null)
+ set_connection_state(null)
+ set_editor_state({kind: node.kind, mode: "edit", node_id: node.id})
+ }, [])
+ const open_connection = useCallback((feed?: Topology_Node) => {
+ set_context_menu(null)
+ set_editor_state(null)
+ set_connection_state({feed_id: feed?.id})
+ }, [])
+ const stage_editor = useCallback((config: Record) => {
+ if(!editor_state)
+ return
+ update_draft(current => {
+ if(editor_state.mode === "edit") {
+ const node = current.nodes.find(item => item.id === editor_state.node_id)
+ if(!node)
+ return current
+ const next_config = {...node.config, ...config}
+ return {...current, nodes: current.nodes.map(item => item.id === node.id ? {...item, key: String(next_config.key), label: String(next_config.key), type: String(next_config.type), enabled: node_enabled(next_config), config: next_config, details: node_details(item.kind, next_config)} : item)}
+ }
+ const key = String(config.key)
+ const id = `draft:${editor_state.kind}:${crypto.randomUUID()}`
+ const node: Topology_Node = {id, kind: editor_state.kind, entity_id: temporary_id.current--, key, label: key, type: String(config.type), enabled: node_enabled(config), config, details: node_details(editor_state.kind, config)}
+ return {...current, nodes: [...current.nodes.filter(item => item.kind !== "client"), node, ...current.nodes.filter(item => item.kind === "client")]}
+ })
+ set_editor_state(null)
+ message.success("配置已暂存")
+ }, [editor_state, update_draft])
+ const stage_connection = useCallback((feed: Topology_Node, source_id: string) => {
+ update_draft(current => {
+ const source = current.nodes.find(node => node.id === source_id)
+ const nodes = current.nodes.map(node => node.id === feed.id ? {...node, config: {...node.config, source_key: source?.key ?? ""}} : node)
+ const edges = current.edges.filter(edge => edge.kind !== "configuration" || edge.target !== feed.id)
+ if(source)
+ edges.push({id: `source-feed:${feed.id}`, kind: "configuration", source: source.id, target: feed.id, active: source.enabled && feed.enabled, details: [{label: "连接状态", value: "已暂存,保存后生效"}, {label: "数据源", value: source.label}, {label: "数据馈送", value: feed.label}]})
+ return {...current, nodes, edges}
+ })
+ set_connection_state(null)
+ set_context_menu(null)
+ message.success(source_id ? "连接已暂存" : "断开连接已暂存")
+ }, [update_draft])
+ const connect_nodes = useCallback((connection: Connection) => {
+ const source = draft?.nodes.find(node => node.id === connection.source)
+ const feed = draft?.nodes.find(node => node.id === connection.target)
+ if(source?.kind !== "data_source" || feed?.kind !== "data_feed")
+ return
+ stage_connection(feed, source.id)
+ }, [draft, stage_connection])
+ const remove_node = useCallback((node: Topology_Node) => {
+ set_context_menu(null)
+ Modal.confirm({title: `删除${node.kind === "data_source" ? "数据源" : "数据馈送"}“${node.label}”?`, content: "删除只会暂存,点击保存拓扑后才下发后台。", okText: "暂存删除", okButtonProps: {danger: true}, cancelText: "取消", onOk: () => {
+ update_draft(current => {
+ const nodes = current.nodes.filter(item => item.id !== node.id).map(item => node.kind === "data_source" && item.kind === "data_feed" && item.config?.source_key === node.key ? {...item, config: {...item.config, source_key: ""}} : item)
+ return {...current, nodes, edges: current.edges.filter(edge => edge.source !== node.id && edge.target !== node.id)}
+ })
+ }})
+ }, [update_draft])
+ const sync_working_view = useCallback((mark_dirty: boolean) => {
+ if(!flow_ref.current || !draft || restoring_ref.current)
+ return
+ const view = capture_flow_view(flow_ref.current, draft, schema.graph.layout_revision)
+ if(mark_dirty && JSON.stringify(view) !== JSON.stringify(working_view_ref.current))
+ set_view_dirty(true)
+ working_view_ref.current = view
+ }, [draft, schema.graph.layout_revision])
+ const on_nodes_change = useCallback>(changes => {
+ set_flow_nodes(nodes => applyNodeChanges(changes, nodes))
+ }, [])
+ const show_context_menu = useCallback((event: Context_Event, target: Context_Menu_State["target"], id?: string) => {
+ if(!shell.current)
+ return
+ set_editor_state(null)
+ set_connection_state(null)
+ set_context_menu({...context_position(event, shell.current), target, id})
+ }, [])
+ const on_node_context_menu = useCallback>((event, node) => show_context_menu(event, "node", node.id), [show_context_menu])
+ const on_edge_context_menu = useCallback>((event, edge) => show_context_menu(event, "edge", edge.id), [show_context_menu])
+ const on_edge_label_context_menu = useCallback((event: React_Mouse_Event, edge_id: string) => show_context_menu(event, "edge", edge_id), [show_context_menu])
useEffect(() => {
- if (!container.current || !data) return
- let disposed = false
- let restoring = true
- let save_timer: number | undefined
- const graph = new Graph({
- container: container.current,
- autoFit: data.view.initialized ? undefined : "view",
- animation: false,
- data: {
- nodes: data.nodes.map(node => ({id: node.id, data: node})),
- edges: data.edges.map(edge => ({id: edge.id, source: edge.source, target: edge.target, data: edge}))
- },
- layout: schema.graph.layout as never,
- behaviors: schema.graph.behaviors,
- node: {
- type: "rect",
- style: datum => {
- const node = datum.data as unknown as Topology_Node
- const kind = kind_by_name.get(node.kind)
- return {
- size: [190, 62],
- radius: 10,
- fill: kind?.fill ?? "#fafafa",
- fillOpacity: node.enabled ? 1 : 0.48,
- stroke: kind?.stroke ?? "#8c8c8c",
- lineWidth: node.enabled ? 2 : 1,
- lineDash: node.enabled ? undefined : [5, 4],
- labelText: `${node.label}\n${node.type}`,
- labelFill: "#1f1f1f",
- labelFontSize: 13,
- labelLineHeight: 18,
- cursor: "pointer"
- }
- }
- },
- edge: {
- type: "polyline",
- style: datum => {
- const edge = datum.data as unknown as Topology_Edge
- return {
- stroke: edge.active ? schema.graph.active_edge_color : schema.graph.inactive_edge_color,
- lineWidth: edge.active ? 2.5 : 1.5,
- lineDash: edge.active ? undefined : [6, 5],
- endArrow: true,
- opacity: edge.active ? 1 : 0.62
- }
- }
- }
- })
- graph.on(NodeEvent.CLICK, event => {
- const target = (event as unknown as {target: {id: string}}).target
- set_selected_id(target.id)
- })
- graph.on(CanvasEvent.CLICK, () => set_selected_id(""))
- const persist_view = async () => {
- if (restoring || disposed) return
- const node_positions: Topology_View["node_positions"] = {}
- for (const node of data.nodes) {
- const [x, y] = graph.getElementPosition(node.id)
- node_positions[node.id] = {x, y}
- }
- const [pan_x, pan_y] = graph.getPosition()
- const payload: Topology_View = {
- protocol: "ecap.data-topology-view",
- protocol_version: 1,
- initialized: true,
- zoom: graph.getZoom(),
- pan_x,
- pan_y,
- node_positions
- }
- try {
- await axios.put(schema.view_api, payload)
- if (!disposed) set_view_error("")
- } catch (reason) {
- if (!disposed) set_view_error(reason instanceof Error ? reason.message : String(reason))
- }
+ if(!draft)
+ return
+ const view = working_view_ref.current ?? draft.view
+ const replace = replace_flow_ref.current
+ const next_nodes = create_flow_nodes(draft, view, schema.graph.layout, schema.graph.layout_revision, kind_by_name, schema.status, status_expansion_ref)
+ const next_edges = create_flow_edges(draft, schema, status_expansion_ref, on_edge_label_context_menu)
+ set_flow_nodes(current => merge_flow_nodes(current, next_nodes, replace))
+ set_flow_edges(current => merge_flow_edges(current, next_edges, replace))
+ replace_flow_ref.current = false
+ set_flow_ready(true)
+ }, [draft, kind_by_name, on_edge_label_context_menu, schema])
+ const initialize_flow = useCallback(async (instance: ReactFlowInstance) => {
+ flow_ref.current = instance
+ restoring_ref.current = true
+ const view = working_view_ref.current ?? draft?.view
+ if(view?.initialized && view.layout_revision === schema.graph.layout_revision)
+ await instance.setViewport({x: view.pan_x, y: view.pan_y, zoom: view.zoom}, {duration: 0})
+ else
+ await instance.fitView({padding: 0.12})
+ if(draft)
+ working_view_ref.current = capture_flow_view(instance, draft, schema.graph.layout_revision)
+ finish_restoring()
+ }, [draft, finish_restoring, schema.graph.layout_revision])
+ const save = useCallback(async () => {
+ if(!baseline || !draft || saving)
+ return
+ set_saving(true)
+ const request = async (method: "post" | "patch" | "delete" | "put", url: string, data?: unknown) => {
+ const response = await axios.request>({method, url, data, timeout: 15000})
+ if(response.data.status !== 0)
+ throw new Error(response.data.msg)
}
- const schedule_view_save = () => {
- if (restoring || disposed) return
- if (save_timer !== undefined) window.clearTimeout(save_timer)
- save_timer = window.setTimeout(() => void persist_view(), 400)
- }
- graph.on(NodeEvent.DRAG_END, schedule_view_save)
- graph.on(GraphEvent.AFTER_TRANSFORM, schedule_view_save)
- graph_ref.current = graph
- const initialize = async () => {
- await graph.render()
- if (disposed) return
- if (data.view.initialized) {
- const positions: Record = {}
- for (const [id, position] of Object.entries(data.view.node_positions)) {
- if (data.nodes.some(node => node.id === id))
- positions[id] = [position.x, position.y]
- }
- if (Object.keys(positions).length > 0)
- await graph.translateElementTo(positions, false)
- await graph.zoomTo(data.view.zoom, false)
- await graph.translateTo([data.view.pan_x, data.view.pan_y], false)
+ try {
+ const current_view = flow_ref.current ? capture_flow_view(flow_ref.current, draft, schema.graph.layout_revision) : working_view_ref.current ?? draft.view
+ working_view_ref.current = current_view
+ const baseline_sources = baseline.nodes.filter(node => node.kind === "data_source")
+ const baseline_feeds = baseline.nodes.filter(node => node.kind === "data_feed")
+ const draft_sources = draft.nodes.filter(node => node.kind === "data_source")
+ const draft_feeds = draft.nodes.filter(node => node.kind === "data_feed")
+ for(const node of draft_sources.filter(node => node.entity_id < 0))
+ await request("post", data_sources_api, node.config)
+ for(const node of draft_feeds.filter(node => node.entity_id > 0)) {
+ const original = baseline_feeds.find(item => item.entity_id === node.entity_id)
+ if(original && JSON.stringify(original.config) !== JSON.stringify(node.config))
+ await request("patch", `${data_feeds_api}/${node.entity_id}`, changed_config(original.config, node.config))
}
- restoring = false
+ for(const node of baseline_feeds.filter(node => !draft_feeds.some(item => item.entity_id === node.entity_id)).sort((left, right) => right.entity_id - left.entity_id))
+ await request("delete", `${data_feeds_api}/${node.entity_id}`)
+ for(const node of draft_sources.filter(node => node.entity_id > 0)) {
+ const original = baseline_sources.find(item => item.entity_id === node.entity_id)
+ if(original && JSON.stringify(original.config) !== JSON.stringify(node.config))
+ await request("patch", `${data_sources_api}/${node.entity_id}`, changed_config(original.config, node.config))
+ }
+ for(const node of baseline_sources.filter(node => !draft_sources.some(item => item.entity_id === node.entity_id)).sort((left, right) => right.entity_id - left.entity_id))
+ await request("delete", `${data_sources_api}/${node.entity_id}`)
+ for(const node of draft_feeds.filter(node => node.entity_id < 0))
+ await request("post", data_feeds_api, node.config)
+ await request("put", schema.view_api, canonical_view(current_view, draft.nodes))
+ await load(true)
+ window.dispatchEvent(new Event(topology_changed_event))
+ message.success("拓扑已保存并同步到后台")
+ } catch(reason) {
+ await load(true)
+ message.error(`保存中断,已同步后台实际状态:${reason instanceof Error ? reason.message : String(reason)}`)
+ } finally {
+ set_saving(false)
}
- void initialize()
- const observer = new ResizeObserver(() => graph.resize())
- observer.observe(container.current)
- return () => {
- if (save_timer !== undefined) window.clearTimeout(save_timer)
- if (!restoring) void persist_view()
- disposed = true
- observer.disconnect()
- graph.destroy()
- graph_ref.current = null
+ }, [baseline, draft, load, saving, schema.graph.layout_revision, schema.view_api])
+ const undo = useCallback(() => {
+ if(!baseline)
+ return
+ replace_flow_ref.current = true
+ set_draft(clone_data(baseline))
+ working_view_ref.current = clone_data(baseline.view)
+ restore_viewport(baseline.view)
+ set_view_dirty(false)
+ set_editor_state(null)
+ set_connection_state(null)
+ set_context_menu(null)
+ message.success("未保存修改已撤销")
+ }, [baseline, restore_viewport])
+ const sync = useCallback(() => {
+ const execute = () => {
+ set_editor_state(null)
+ set_connection_state(null)
+ set_context_menu(null)
+ void load(true)
}
- }, [data, kind_by_name, schema.graph, schema.view_api])
-
- return
-
-
- {schema.editors.map(item => )}
- }>
+ if(dirty)
+ Modal.confirm({title: "放弃未保存修改并同步后台?", content: "本地拓扑草稿将被后台当前配置替换。", okText: "同步后台", cancelText: "取消", onOk: execute})
+ else
+ execute()
+ }, [dirty, load])
+ const fit_view = useCallback(async () => {
+ if(!flow_ref.current)
+ return
+ await flow_ref.current.fitView({padding: 0.12})
+ sync_working_view(true)
+ }, [sync_working_view])
+ const layout = useCallback(() => {
+ if(!draft)
+ return
+ const positions = topology_layout(draft, schema.graph.layout)
+ set_flow_nodes(nodes => nodes.map(node => ({...node, position: positions.get(node.id)!})))
+ const viewport = flow_ref.current?.getViewport() ?? {x: 0, y: 0, zoom: 1}
+ const node_positions: Topology_View["node_positions"] = {}
+ for(const node of draft.nodes) {
+ if(node.kind !== "client")
+ node_positions[node.id] = positions.get(node.id)!
+ }
+ working_view_ref.current = {protocol: "ecap.data-topology-view", protocol_version: 1, initialized: true, layout_revision: schema.graph.layout_revision, zoom: viewport.zoom, pan_x: viewport.x, pan_y: viewport.y, node_positions}
+ set_view_dirty(true)
+ window.requestAnimationFrame(() => void fit_view())
+ }, [draft, fit_view, schema.graph.layout, schema.graph.layout_revision])
+ const context_items = useMemo(() => {
+ if(!context_menu)
+ return []
+ if(context_menu.target === "canvas")
+ return [{key: "create-source", icon: , label: "新建数据源", onClick: () => open_create("data_source")}, {key: "create-feed", icon: , label: "新建数据馈送", onClick: () => open_create("data_feed")}, {type: "divider"}, {key: "create-connection", icon: , label: "新建连接", onClick: () => open_connection()}]
+ if(context_menu.target === "edge") {
+ const edge = draft?.edges.find(item => item.id === context_menu.id)
+ const feed = feeds.find(item => item.id === edge?.target)
+ return edge?.kind === "configuration" && feed ? [{key: "edit-connection", icon: , label: "编辑连接", onClick: () => open_connection(feed)}, {key: "disconnect", icon: , danger: true, label: "断开连接", onClick: () => stage_connection(feed, "")}] : [{key: "runtime", disabled: true, label: "运行时客户端连接"}]
+ }
+ const node = draft?.nodes.find(item => item.id === context_menu.id)
+ if(!node)
+ return []
+ if(node.kind === "client")
+ return [{key: "client", disabled: true, label: `${node.type} ${node.label}`}]
+ const items: NonNullable = [{key: "edit", icon: , label: "编辑配置", onClick: () => open_edit(node)}]
+ if(node.kind === "data_feed" && !draft?.edges.some(edge => edge.kind === "configuration" && edge.target === node.id))
+ items.push({key: "connection", icon: , label: "添加连接", onClick: () => open_connection(node)})
+ items.push({type: "divider"}, {key: "delete", icon: , danger: true, label: "删除节点", onClick: () => remove_node(node)})
+ return items
+ }, [context_menu, draft, feeds, open_connection, open_create, open_edit, remove_node, stage_connection])
+ const active_editor = editor_state ? editor_by_kind.get(editor_state.kind) : undefined
+ const editor_node = editor_state?.node_id ? draft?.nodes.find(node => node.id === editor_state.node_id) : undefined
+ const title = {schema.title}{dirty && 未保存}
+ return }>
{error && }
- {view_error && set_view_error("")} style={{marginBottom: 12}}/>}
- {!data && !error ?
: data &&
-
-
set_editor_kind(selected.kind)}>打开编辑器}>
- {selected ?
- {selected_kind?.title ?? selected.kind}{selected.enabled ? "已启用" : "未启用"}
-
-
- : }
-
+ {!draft || !flow_ready ?
:
+
+
nodes={flow_nodes} edges={flow_edges} nodeTypes={flow_node_types} edgeTypes={flow_edge_types} onNodesChange={on_nodes_change} onConnect={connect_nodes} onInit={instance => void initialize_flow(instance)} onNodeDrag={() => sync_working_view(true)} onNodeDragStop={() => sync_working_view(true)} onMoveStart={event => {
+ viewport_interaction_ref.current = Boolean(event && (!(event.target instanceof Element) || !event.target.closest(".nodrag,.nopan")))
+ }} onMoveEnd={() => {
+ if(viewport_interaction_ref.current)
+ sync_working_view(true)
+ viewport_interaction_ref.current = false
+ }} onNodeContextMenu={on_node_context_menu} onEdgeContextMenu={on_edge_context_menu} onPaneContextMenu={event => show_context_menu(event, "canvas")} onPaneClick={() => set_context_menu(null)} nodesConnectable edgesReconnectable={false} deleteKeyCode={null} zoomOnDoubleClick={false} minZoom={0.15} maxZoom={2.5} proOptions={{hideAttribution: true}}>
+
+ set_view_dirty(true)} onZoomOut={() => set_view_dirty(true)} onFitView={() => set_view_dirty(true)}/>
+
+ {context_menu &&
event.stopPropagation()} style={{position: "absolute", left: context_menu.x, top: context_menu.y, zIndex: 30, minWidth: 190, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8, boxShadow: "0 10px 28px rgba(0,0,0,.16)", overflow: "hidden"}}>
}
+ {editor_state && active_editor &&
set_editor_state(null)}/>}
+ {connection_state && set_connection_state(null)}/>}
}
-
{
- set_editor_kind("")
- void load()
- }}>
- {editor && }
-
+ {draft &&
{schema.node_kinds.map(kind => {kind.title})}右键画布可新建节点或连接;右键连接线可编辑或断开;所有修改点击保存后才同步后台。
}
}
-
export function DataTopologyPage() {
const [schema, set_schema] = useState
(null)
const [error, set_error] = useState("")
@@ -344,19 +794,22 @@ export function DataTopologyPage() {
const response = await axios.get>("/api/adminive/config/data_topology/schema", {signal: controller.signal})
set_schema(response.data.data)
set_error("")
- } catch (reason) {
- if (!axios.isCancel(reason)) set_error(reason instanceof Error ? reason.message : String(reason))
+ } catch(reason) {
+ if(!axios.isCancel(reason))
+ set_error(reason instanceof Error ? reason.message : String(reason))
}
}
- const handle_topology_changed = () => void load_schema()
+ const changed = () => void load_schema()
void load_schema()
- window.addEventListener(topology_changed_event, handle_topology_changed)
+ window.addEventListener(topology_changed_event, changed)
return () => {
controller.abort()
- window.removeEventListener(topology_changed_event, handle_topology_changed)
+ window.removeEventListener(topology_changed_event, changed)
}
}, [])
- if (error) return
- if (!schema) return
+ if(error)
+ return
+ if(!schema)
+ return
return
}
diff --git a/src/Adminive/amis_runtime.css b/src/Adminive/amis_runtime.css
new file mode 100644
index 0000000..0a9f131
--- /dev/null
+++ b/src/Adminive/amis_runtime.css
@@ -0,0 +1,11 @@
+.ecap-amis-overlay-root {
+ position: fixed !important;
+ inset: 0 !important;
+ z-index: 2000 !important;
+ background: transparent !important;
+ overflow: visible !important;
+ pointer-events: none !important;
+}
+.ecap-amis-overlay-root > * {
+ pointer-events: auto !important;
+}
diff --git a/src/Adminive/amis_runtime.ts b/src/Adminive/amis_runtime.ts
index 09b4629..4bf3a01 100644
--- a/src/Adminive/amis_runtime.ts
+++ b/src/Adminive/amis_runtime.ts
@@ -1,3 +1,4 @@
+import "./amis_runtime.css"
type Amis_Request = {
url: string
method?: string
@@ -6,7 +7,7 @@ type Amis_Request = {
responseType?: XMLHttpRequest["responseType"]
config?: Record
}
-type Amis_Fetcher = (request: Amis_Request) => Promise
+export type Amis_Fetcher = (request: Amis_Request) => Promise
interface Amis_Scoped {
unmount(): void
}
@@ -19,10 +20,19 @@ declare global {
}
}
export type {Amis_Request}
-export function embed_amis(container: HTMLElement, schema: Record, fetcher: Amis_Fetcher): Amis_Scoped {
- if (!window.amisRequire) {
+let overlay_root: HTMLDivElement | null = null
+function get_overlay_root(): HTMLDivElement {
+ if(overlay_root)
+ return overlay_root
+ overlay_root = document.createElement("div")
+ overlay_root.className = "amis-scope ecap-amis-overlay-root"
+ document.body.appendChild(overlay_root)
+ return overlay_root
+}
+export function embed_amis(container: HTMLElement, schema: Record, fetcher: Amis_Fetcher, data: Record = {}): Amis_Scoped {
+ if(!window.amisRequire) {
throw new Error("AMIS SDK runtime is not loaded")
}
const module = window.amisRequire("amis/embed") as Amis_Embed_Module
- return module.embed(container, schema, {}, {fetcher, theme: "cxd"})
+ return module.embed(container, schema, {data}, {fetcher, getModalContainer: get_overlay_root, theme: "cxd"})
}
diff --git a/vite.config.js b/vite.config.js
index aaf140e..aefa26c 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -139,10 +139,12 @@ const spaAliasPaths = new Set([
'/map3d',
'/aircraftlist',
'/settings',
+ '/topology',
'/ui/map',
'/ui/map3d',
'/ui/aircraftlist',
- '/ui/settings'
+ '/ui/settings',
+ '/ui/topology'
])
function spaAliasesPlugin() {
return {