|
|
@@ -0,0 +1,630 @@
|
|
|
+import { ref } from 'vue';
|
|
|
+import * as echarts from 'echarts';
|
|
|
+import type { EChartsOption } from 'echarts';
|
|
|
+import { message, Modal } from 'ant-design-vue';
|
|
|
+import { useMineDepartmentStore } from '/@/store/modules/mine';
|
|
|
+import {
|
|
|
+ createGraphOption,
|
|
|
+ categories,
|
|
|
+ levelColorMap,
|
|
|
+ nodeDetailFields,
|
|
|
+ POINT_COLOR,
|
|
|
+ POINT_SIZE,
|
|
|
+ JUDGE_COLOR,
|
|
|
+ JUDGE_WIDTH,
|
|
|
+ LAYOUT,
|
|
|
+} from '../windTopology.data';
|
|
|
+import type { TopologyData, TopoNodeData } from '../windTopology.data';
|
|
|
+import { getTopologyData, updateArea, addMineAreaRelation, deleteMineAreaRelation, updateMineAreaTopology } from '../windTopology.api';
|
|
|
+import { computeLayout } from './useTopologyLayout';
|
|
|
+
|
|
|
+/** 拓扑交互模式:relation=绑定模式(关系编辑)、device=布点模式(设备绑定/解绑) */
|
|
|
+export type TopoMode = 'relation' | 'device';
|
|
|
+
|
|
|
+export function useTopology() {
|
|
|
+ const chartRef = ref<HTMLDivElement | null>(null);
|
|
|
+ let chartInstance: echarts.ECharts | null = null;
|
|
|
+ let topologyData: TopologyData = { nodes: [], links: [] };
|
|
|
+ /** 测风装置显示名映射(rawId → devicePos/deviceCode/id,取自全量 windrectList):
|
|
|
+ * 已绑定设备不再绘制节点,其名称由该映射写入巷道连线 edgeLabel 及解绑确认文案 */
|
|
|
+ let deviceNameMap: Record<string, string> = {};
|
|
|
+ let resizeObserver: ResizeObserver | null = null;
|
|
|
+
|
|
|
+ // —— 模式状态 ——
|
|
|
+ /** 当前模式:默认绑定模式;两种模式均保留悬浮高亮与单击详情 */
|
|
|
+ const mode = ref<TopoMode>('relation');
|
|
|
+ /** 绑定模式选中的关联源节点(双击节点选中,再单击另一节点建立关系;红色描边高亮) */
|
|
|
+ const armedSource = ref<TopoNodeData | null>(null);
|
|
|
+ /** 布点模式绑定弹窗:双击未绑定布点的连线后弹出选择测风装置 */
|
|
|
+ const bindVisible = ref(false);
|
|
|
+ const bindPoint = ref<TopoNodeData | null>(null);
|
|
|
+ const bindDeviceId = ref<string | undefined>(undefined);
|
|
|
+ /** 操作栏部门选择:选中任意层级部门后过滤部门树(该部门及其后代)并按该部门查询数据;空=全局 */
|
|
|
+ const selectedDeptId = ref('');
|
|
|
+
|
|
|
+ // —— 选中节点详情 ——
|
|
|
+ const selectedNode = ref<TopoNodeData | null>(null);
|
|
|
+ const detailFields = ref<any[]>([]);
|
|
|
+
|
|
|
+ // —— 初始化 ——
|
|
|
+ function initChart(el: HTMLDivElement) {
|
|
|
+ chartInstance = echarts.init(el);
|
|
|
+ window.addEventListener('resize', handleResize);
|
|
|
+ // 容器尺寸变化(如 Tab 由隐藏变为可见 / 窗口尺寸变化)时自适应重绘,
|
|
|
+ // 避免 0×0 初始化导致节点布局越界而空白
|
|
|
+ if (typeof ResizeObserver !== 'undefined') {
|
|
|
+ resizeObserver = new ResizeObserver(() => {
|
|
|
+ if (!chartInstance) return;
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
+ if (rect.width > 0 && rect.height > 0) {
|
|
|
+ chartInstance.resize();
|
|
|
+ renderTopology(topologyData);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ resizeObserver.observe(el);
|
|
|
+ }
|
|
|
+ return chartInstance;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadTopology() {
|
|
|
+ try {
|
|
|
+ const mineStore = useMineDepartmentStore();
|
|
|
+ // 仅支持矿端(叶子节点)视图:deptId 必须是组织树中的矿井叶子;
|
|
|
+ // 未选中或选中非矿端(根/中间部门)时自动定位到第一个矿端
|
|
|
+ let mine = selectedDeptId.value ? mineStore.findDepartById(selectedDeptId.value) : undefined;
|
|
|
+ if (!mine || !mine.isLeaf) {
|
|
|
+ mine = mineStore.findDepart((n) => n.isLeaf, mineStore.getDepartTree);
|
|
|
+ }
|
|
|
+ if (!mine) {
|
|
|
+ // 组织树未就绪(极端情况):短暂等待后重试一次
|
|
|
+ console.warn('组织树未就绪,300ms 后重试加载拓扑');
|
|
|
+ await new Promise((r) => setTimeout(r, 300));
|
|
|
+ mine = mineStore.findDepart((n) => n.isLeaf, mineStore.getDepartTree);
|
|
|
+ }
|
|
|
+ if (!mine) {
|
|
|
+ console.error('组织树未就绪或不存在矿端,无法加载拓扑');
|
|
|
+ message.error('组织树未就绪,无法加载拓扑');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ selectedDeptId.value = mine.id;
|
|
|
+ // 接口获取 → 代码处理(关系/疑似标记/地面节点)已由 getTopologyData 完成,
|
|
|
+ // 此处直接消费全量数据;接口异常时 bundle 为备用空数据(仍可绘制总进地面节点)
|
|
|
+ const bundle = await getTopologyData({ deptId: mine.id });
|
|
|
+ armedSource.value = null;
|
|
|
+ // 测风装置显示名映射(全量列表 → 已绑定设备的 edgeLabel/解绑文案展示用)
|
|
|
+ deviceNameMap = {};
|
|
|
+ for (const d of bundle.windrectList) {
|
|
|
+ if (d.id) deviceNameMap[String(d.id)] = d.devicePos || d.deviceCode || String(d.id);
|
|
|
+ }
|
|
|
+ renderTopology(bundle.topology);
|
|
|
+ // 数据为空时给出诊断信息(总进地面节点仍会渲染)
|
|
|
+ if (!bundle.topology.nodes.some((n) => n.category !== 0)) {
|
|
|
+ console.warn('拓扑数据为空:未获取到测风地点(deptId=' + mine.id + ')');
|
|
|
+ message.warning('未获取到拓扑数据,请检查接口或稍后刷新');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('拓扑数据加载失败:', e);
|
|
|
+ message.error('拓扑数据加载失败,请查看控制台日志');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 操作栏煤矿选择:仅允许选择矿端(叶子节点)并按所选矿查询数据。
|
|
|
+ * MineCascader 已设 change-on-select=false,change 仅在选中叶子(矿端)时触发;
|
|
|
+ * 此处再兜底校验,非矿端(根/中间部门/空值)自动回退到第一个矿端。
|
|
|
+ * 注意:需先更新 selectedDeptId 再加载(不能按值去抖,否则恒等跳过) */
|
|
|
+ function setSelectedDept(id: string) {
|
|
|
+ const mineStore = useMineDepartmentStore();
|
|
|
+ const node = id ? mineStore.findDepartById(id) : undefined;
|
|
|
+ if (!node || !node.isLeaf) {
|
|
|
+ const firstMine = mineStore.findDepart((n) => n.isLeaf, mineStore.getDepartTree);
|
|
|
+ if (!firstMine) {
|
|
|
+ message.warning('未找到矿端,请检查组织树数据');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ selectedDeptId.value = firstMine.id;
|
|
|
+ } else {
|
|
|
+ selectedDeptId.value = node.id;
|
|
|
+ }
|
|
|
+ loadTopology();
|
|
|
+ }
|
|
|
+
|
|
|
+ // —— 渲染拓扑 ——
|
|
|
+ function renderTopology(data: TopologyData) {
|
|
|
+ topologyData = data;
|
|
|
+ if (!chartInstance) return;
|
|
|
+
|
|
|
+ const option: EChartsOption = createGraphOption();
|
|
|
+ const series: any = (option.series as any[])[0];
|
|
|
+
|
|
|
+ const cw = chartInstance.getWidth();
|
|
|
+ const ch = chartInstance.getHeight();
|
|
|
+ // 列式布局(总进 → lv0..lv6 → 未分级 → 总回)
|
|
|
+ const layout = computeLayout(data, cw, ch);
|
|
|
+ const pointById = new Map(data.nodes.filter((n) => n.category === 1).map((n) => [n.id, n]));
|
|
|
+ // roadway 边主要数据点位(名称已在连线中点展示,节点自身不再显示文本标签,避免重复)
|
|
|
+ const roadwayMainIds = new Set(data.links.filter((l) => l.kind === 'roadway' && l.mainPointId).map((l) => l.mainPointId));
|
|
|
+
|
|
|
+ // 最终按 id 去重兜底(数据层已去重,此处防止异常数据导致 ECharts 重复 id 报错)
|
|
|
+ const seenIds = new Set<string>();
|
|
|
+ const echartsNodes: any[] = [];
|
|
|
+ for (const n of data.nodes) {
|
|
|
+ if (seenIds.has(n.id)) {
|
|
|
+ console.warn('拓扑节点 id 重复,已跳过:', n.id);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ seenIds.add(n.id);
|
|
|
+ const cat = categories[n.category] || categories[0];
|
|
|
+ // 节点配色:测风地点统一灰色小圆点(层级/类型区分转移到连线颜色);地面节点使用分类色
|
|
|
+ let nodeColor = cat.color;
|
|
|
+ if (n.category === 1) {
|
|
|
+ nodeColor = POINT_COLOR;
|
|
|
+ }
|
|
|
+ const itemStyle: any = { color: nodeColor };
|
|
|
+ // 测风地点统一为灰色小圆点(白描边衬底,避免与彩色连线粘连)
|
|
|
+ if (n.category === 1) {
|
|
|
+ itemStyle.borderColor = '#ffffff';
|
|
|
+ itemStyle.borderWidth = 1.5;
|
|
|
+ }
|
|
|
+ // 绑定模式选中的关联源节点:红色醒目标记
|
|
|
+ if (armedSource.value && armedSource.value.id === n.id) {
|
|
|
+ itemStyle.borderColor = '#f5222d';
|
|
|
+ itemStyle.borderWidth = 3;
|
|
|
+ }
|
|
|
+ const pos = layout.positions[n.id];
|
|
|
+ const en: any = {
|
|
|
+ id: n.id,
|
|
|
+ name: n.name,
|
|
|
+ category: n.category,
|
|
|
+ value: n.name,
|
|
|
+ symbol: n.category === 1 ? 'circle' : cat.symbol,
|
|
|
+ symbolSize: n.category === 1 ? POINT_SIZE : cat.symbolSize,
|
|
|
+ itemStyle,
|
|
|
+ raw: n,
|
|
|
+ };
|
|
|
+ // 巷道连线主要数据点位:名称已在连线中点标注展示,节点本身不再显示文本标签(避免重复)
|
|
|
+ if (n.category === 1 && roadwayMainIds.has(n.id)) {
|
|
|
+ en.label = { show: false };
|
|
|
+ }
|
|
|
+ if (pos) {
|
|
|
+ en.x = pos.x;
|
|
|
+ en.y = pos.y;
|
|
|
+ // 地面节点固定不可拖;测风地点可拖动(配合"保存布局"持久化位置)
|
|
|
+ if (n.category === 0) {
|
|
|
+ en.fixed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ echartsNodes.push(en);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制层级:同一 series 内后绘制者在上。地面保持最底绘制
|
|
|
+ const renderPriority: Record<number, number> = { 0: 0, 1: 1 };
|
|
|
+ echartsNodes.sort((a, b) => (renderPriority[a.category] ?? 0) - (renderPriority[b.category] ?? 0));
|
|
|
+ series.nodes = echartsNodes;
|
|
|
+ series.links = data.links
|
|
|
+ .map((l) => {
|
|
|
+ const link: any = {
|
|
|
+ source: l.source,
|
|
|
+ target: l.target,
|
|
|
+ kind: l.kind,
|
|
|
+ pointId: l.pointId,
|
|
|
+ mainPointId: l.mainPointId,
|
|
|
+ relationId: l.relationId,
|
|
|
+ flow: l.flow,
|
|
|
+ };
|
|
|
+ if (l.kind === 'roadway') {
|
|
|
+ // 巷道连线:直线(无曲率)+ 按主要数据点位 level 用 levelColorMap 着色 + 风流方向箭头;
|
|
|
+ // 疑似隐蔽工作面巷道(主要数据点位 judgeAreaList 字段有内容 → 节点 suspected)标红加粗;
|
|
|
+ // 中点标注(edgeLabel rich 富文本):第一行主要数据点位名称、第二行风量、第三行圆点 + 已绑定设备名;
|
|
|
+ // 未绑定时第三行输出浅灰占位圆点(dotEmpty),保证标签高度恒定
|
|
|
+ const point = l.mainPointId ? pointById.get(l.mainPointId) : undefined;
|
|
|
+ const lv = point ? Number(point.level) : NaN;
|
|
|
+ const deviceName = point?.windrectId ? deviceNameMap[point.windrectId] : '';
|
|
|
+ const suspected = point?.suspected;
|
|
|
+ // 常态标注:仅名称;高亮标注:名称 + 圆点与已绑定布点名(未绑定输出浅灰占位圆点)
|
|
|
+ link.midLabel = point ? `{name|${point.name}}` : '';
|
|
|
+ link.midLabelFull = point ? `{name|${point.name}}\n${deviceName ? `{dot|● }{device|${deviceName}}` : '{dotEmpty|● }'}` : '';
|
|
|
+ link.lineStyle = {
|
|
|
+ color: suspected ? JUDGE_COLOR : levelColorMap[lv] || POINT_COLOR,
|
|
|
+ width: suspected ? JUDGE_WIDTH : 3,
|
|
|
+ opacity: suspected ? 1 : 0.85,
|
|
|
+ curveness: 0,
|
|
|
+ };
|
|
|
+ link.edgeSymbol = ['none', 'arrow'];
|
|
|
+ link.edgeSymbolSize = [0, 10];
|
|
|
+ } else {
|
|
|
+ link.lineStyle = { color: '#8c8c8c', width: 1.5, opacity: 0.6, curveness: 0 };
|
|
|
+ }
|
|
|
+ return link;
|
|
|
+ })
|
|
|
+ .filter(Boolean);
|
|
|
+
|
|
|
+ chartInstance.setOption(option, true);
|
|
|
+ registerEvents();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 交互事件 ====================
|
|
|
+
|
|
|
+ function registerEvents() {
|
|
|
+ if (!chartInstance) return;
|
|
|
+ chartInstance.off('click');
|
|
|
+ chartInstance.off('dblclick');
|
|
|
+
|
|
|
+ // ——— 单击 ———
|
|
|
+ chartInstance.on('click', (params: any) => {
|
|
|
+ if (!params.data) {
|
|
|
+ // 单击空白:清除选中
|
|
|
+ clearArmed();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (params.dataType === 'node') {
|
|
|
+ const raw = params.data.raw as TopoNodeData;
|
|
|
+ if (!raw) return;
|
|
|
+ // 绑定模式:已选中关联源且单击另一节点 → 显示详情的同时建立关系
|
|
|
+ if (mode.value === 'relation' && armedSource.value && armedSource.value.id !== raw.id) {
|
|
|
+ showNodeDetail(raw);
|
|
|
+ handleNodeRelation(raw);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showNodeDetail(raw);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (params.dataType === 'edge') {
|
|
|
+ // 单击巷道连线 → 显示其主要数据测风地点详情(进风/用风为子节点、回风为父节点)
|
|
|
+ const mainId = params.data.mainPointId || params.data.pointId;
|
|
|
+ if (params.data.kind === 'roadway' && mainId) {
|
|
|
+ const point = topologyData.nodes.find((n) => n.id === mainId);
|
|
|
+ if (point) showNodeDetail(point);
|
|
|
+ }
|
|
|
+ clearArmed();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ——— 双击 ———
|
|
|
+ chartInstance.on('dblclick', (params: any) => {
|
|
|
+ if (!params.data) return;
|
|
|
+ if (params.dataType === 'edge' && params.data.kind === 'roadway') {
|
|
|
+ if (mode.value === 'relation') {
|
|
|
+ // 绑定模式:双击连线 → 解除巷道关系(传关系 id)
|
|
|
+ handleRemoveRelation(params.data);
|
|
|
+ } else {
|
|
|
+ // 布点模式:双击连线 → 对其主要数据点位解绑/绑定测风装置
|
|
|
+ const mainId = params.data.mainPointId || params.data.pointId;
|
|
|
+ const point = mainId ? topologyData.nodes.find((n) => n.id === mainId) : undefined;
|
|
|
+ if (point) handleDeviceAction(point);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (params.dataType === 'node') {
|
|
|
+ const raw = params.data.raw as TopoNodeData;
|
|
|
+ if (!raw) return;
|
|
|
+ if (mode.value === 'relation') {
|
|
|
+ // 绑定模式:双击节点 → 选中/取消选中为关联源
|
|
|
+ armedSource.value = armedSource.value && armedSource.value.id === raw.id ? null : raw;
|
|
|
+ renderTopology(topologyData);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 绑定模式:关系编辑 ====================
|
|
|
+
|
|
|
+ /** 当前选中矿的矿编码(fax),供关联接口使用 */
|
|
|
+ function currentMineCode(): string {
|
|
|
+ const mineStore = useMineDepartmentStore();
|
|
|
+ return mineStore.findDepartById(selectedDeptId.value)?.fax || selectedDeptId.value;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 清除绑定模式的关联源选中态 */
|
|
|
+ function clearArmed() {
|
|
|
+ if (!armedSource.value) return;
|
|
|
+ armedSource.value = null;
|
|
|
+ renderTopology(topologyData);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 绑定模式:双击节点 A 后单击节点 B → 建立巷道关系。
|
|
|
+ * 父子按 level 判定:level 小者为父、大者为子;level 相同时提示不能绑定。
|
|
|
+ */
|
|
|
+ function handleNodeRelation(target: TopoNodeData) {
|
|
|
+ const source = armedSource.value;
|
|
|
+ if (!source) return;
|
|
|
+ const sl = Number(source.level);
|
|
|
+ const tl = Number(target.level);
|
|
|
+ if (sl === tl) {
|
|
|
+ message.warning('两个点位层级相同,不能建立巷道关系');
|
|
|
+ clearArmed();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const parent = sl < tl ? source : target;
|
|
|
+ const child = parent === source ? target : source;
|
|
|
+ if (!parent.rawId || !child.rawId) {
|
|
|
+ message.warning('缺少点位 id,无法建立关系');
|
|
|
+ clearArmed();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ Modal.confirm({
|
|
|
+ title: '确认建立巷道关系',
|
|
|
+ content: `确定建立"${parent.name} → ${child.name}"的巷道关系?`,
|
|
|
+ okText: '确认',
|
|
|
+ cancelText: '取消',
|
|
|
+ onOk: async () => {
|
|
|
+ try {
|
|
|
+ await addMineAreaRelation({ mineCode: currentMineCode(), parentId: parent.rawId, childId: child.rawId });
|
|
|
+ clearArmed();
|
|
|
+ await loadTopology();
|
|
|
+ message.success('关联成功');
|
|
|
+ } catch {
|
|
|
+ message.error('关联失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onCancel: () => clearArmed(),
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 绑定模式:双击连线 → 解除巷道关系(deleteMineAreaRelation 传入关系 id) */
|
|
|
+ function handleRemoveRelation(link: any) {
|
|
|
+ if (!link.relationId) {
|
|
|
+ message.info('该连线为根连线,无可解除的巷道关系');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const sourceNode = topologyData.nodes.find((n) => n.id === link.source);
|
|
|
+ const targetNode = topologyData.nodes.find((n) => n.id === link.target);
|
|
|
+ const text = `${sourceNode?.name || link.source} → ${targetNode?.name || link.target}`;
|
|
|
+ Modal.confirm({
|
|
|
+ title: '确认解除巷道关系',
|
|
|
+ content: `确定解除"${text}"的巷道关系?解除后该连线将消失。`,
|
|
|
+ okText: '确认解除',
|
|
|
+ cancelText: '取消',
|
|
|
+ onOk: async () => {
|
|
|
+ try {
|
|
|
+ await deleteMineAreaRelation({ ids: link.relationId });
|
|
|
+ await loadTopology();
|
|
|
+ message.success('解除巷道关系成功');
|
|
|
+ } catch {
|
|
|
+ message.error('解除巷道关系失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 布点模式:设备绑定/解绑 ====================
|
|
|
+
|
|
|
+ /** 按测风装置原始 id 解析其显示名(用于确认文案,避免直接展示 id) */
|
|
|
+ function deviceNameOf(rawId?: string) {
|
|
|
+ return (rawId && deviceNameMap[rawId]) || rawId || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 布点模式:双击连线 → 已有测风装置则解绑,否则弹窗选择绑定 */
|
|
|
+ function handleDeviceAction(point: TopoNodeData) {
|
|
|
+ if (point.windrectId) {
|
|
|
+ confirmUnbindDevice(point);
|
|
|
+ } else {
|
|
|
+ bindPoint.value = point;
|
|
|
+ bindDeviceId.value = undefined;
|
|
|
+ bindVisible.value = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 关闭布点绑定弹窗(index.vue 取消/关闭时同步,成功关闭由 confirmBind 内部调用) */
|
|
|
+ function closeBindDialog() {
|
|
|
+ bindVisible.value = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 布点模式:弹窗选择测风装置后调用(updateMineArea 更新 windrectId 字段) */
|
|
|
+ async function confirmBind(deviceId?: string) {
|
|
|
+ const point = bindPoint.value;
|
|
|
+ if (!point?.rawId) {
|
|
|
+ message.warning('缺少测风地点原始 id,无法绑定');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!deviceId) {
|
|
|
+ message.warning('请选择数据点位');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await updateArea({ id: point.rawId, windrectId: deviceId });
|
|
|
+ closeBindDialog();
|
|
|
+ await loadTopology();
|
|
|
+ message.success('绑定成功');
|
|
|
+ } catch {
|
|
|
+ message.error('绑定失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 布点模式:解绑测风装置(清空 windrectId 字段,updateMineArea) */
|
|
|
+ function confirmUnbindDevice(point: TopoNodeData) {
|
|
|
+ const deviceName = deviceNameOf(point.windrectId);
|
|
|
+ Modal.confirm({
|
|
|
+ title: '确认解绑',
|
|
|
+ content: `确定解除"${point.name}"与测风装置"${deviceName}"的绑定关系?`,
|
|
|
+ okText: '确认解绑',
|
|
|
+ cancelText: '取消',
|
|
|
+ onOk: async () => {
|
|
|
+ if (!point.rawId) {
|
|
|
+ message.warning('缺少测风地点原始 id,无法解绑');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await updateArea({ id: point.rawId, windrectId: '' });
|
|
|
+ await loadTopology();
|
|
|
+ message.success('解绑成功');
|
|
|
+ } catch {
|
|
|
+ message.error('解绑失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 布局保存 ====================
|
|
|
+
|
|
|
+ /** 获取当前 graph 系列的 SeriesData(ECharts 类型中 getModel 为私有,运行时可用) */
|
|
|
+ function seriesDataOf(): any {
|
|
|
+ if (!chartInstance) return null;
|
|
|
+ const seriesModel = (chartInstance as any).getModel().getSeriesByIndex(0) as any;
|
|
|
+ return seriesModel?.getData?.() || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 ECharts 实例一次性读取全部节点当前渲染坐标(含拖动后位置)。
|
|
|
+ * 坐标来源(按优先级):
|
|
|
+ * 1. SeriesData.getItemLayout(i)——渲染层布局,节点拖动后由 ECharts 写入,
|
|
|
+ * 注意 graph 返回格式为 [x, y] 数组(非 {x, y} 对象);
|
|
|
+ * 2. getRawDataItem(i) 的 x/y(初始 setOption 传入的坐标)。
|
|
|
+ * 节点 id 通过 data.getId(i) 关联(renderTopology 中 en.id = n.id)。
|
|
|
+ */
|
|
|
+ function getChartNodePositions(): Map<string, { x: number; y: number }> {
|
|
|
+ const positions = new Map<string, { x: number; y: number }>();
|
|
|
+ const data = seriesDataOf();
|
|
|
+ if (!data) return positions;
|
|
|
+ const count = data.count();
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
+ const id = data.getId?.(i);
|
|
|
+ if (id == null || id === '') continue;
|
|
|
+ let x: number | undefined;
|
|
|
+ let y: number | undefined;
|
|
|
+ // 1) 渲染层布局:graph 拖动后坐标为 [x, y] 数组;兼容对象形式
|
|
|
+ const layout = data.getItemLayout?.(i);
|
|
|
+ if (Array.isArray(layout) && layout.length >= 2) {
|
|
|
+ x = layout[0];
|
|
|
+ y = layout[1];
|
|
|
+ } else if (layout && typeof layout.x === 'number' && typeof layout.y === 'number') {
|
|
|
+ x = layout.x;
|
|
|
+ y = layout.y;
|
|
|
+ }
|
|
|
+ // 2) 回退:原始 data item 的 x/y(初始布局坐标)
|
|
|
+ if (!(Number.isFinite(x) && Number.isFinite(y))) {
|
|
|
+ const raw = data.getRawDataItem?.(i);
|
|
|
+ if (raw && Number.isFinite(raw.x) && Number.isFinite(raw.y)) {
|
|
|
+ x = raw.x;
|
|
|
+ y = raw.y;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
|
+ positions.set(String(id), { x: x as number, y: y as number });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return positions;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保存当前拓扑布局:将全部测风地点(含 level 0/6)当前位置写入
|
|
|
+ * updateMineAreaTopology 的 topologyX/topologyY 字段(ECharts 画布像素坐标)。
|
|
|
+ * 坐标在点击保存时一次性从 ECharts 实例读取,并按 LAYOUT.snapGrid(对齐像素值)取整。
|
|
|
+ * 返回是否全部保存成功。
|
|
|
+ */
|
|
|
+ async function saveTopologyLayout(): Promise<boolean> {
|
|
|
+ const points = topologyData.nodes.filter((n) => n.category === 1 && n.rawId);
|
|
|
+ if (points.length === 0) {
|
|
|
+ message.warning('当前无可保存的测风地点');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ const positions = getChartNodePositions();
|
|
|
+ const grid = LAYOUT.snapGrid || 10;
|
|
|
+ // 仅提交能读到坐标的节点(缺失坐标的节点跳过,避免 Promise/空对象混入请求体);
|
|
|
+ // 保存时按配置的对齐像素值取整(对齐网格)
|
|
|
+ const payload = points
|
|
|
+ .map((p) => ({ p, pos: positions.get(p.id) }))
|
|
|
+ .filter((x): x is { p: TopoNodeData; pos: { x: number; y: number } } => Boolean(x.pos))
|
|
|
+ .map(({ p, pos }) => ({
|
|
|
+ id: p.rawId,
|
|
|
+ topologyX: Math.round(pos.x / grid) * grid,
|
|
|
+ topologyY: Math.round(pos.y / grid) * grid,
|
|
|
+ }));
|
|
|
+ if (payload.length === 0) {
|
|
|
+ message.warning('未能读取节点坐标,布局未保存');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ await updateMineAreaTopology(payload);
|
|
|
+ message.success('布局保存完成');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 节点详情 ====================
|
|
|
+
|
|
|
+ function showNodeDetail(node: TopoNodeData) {
|
|
|
+ selectedNode.value = node;
|
|
|
+ const catName = categories[node.category]?.name || '';
|
|
|
+ const fields = nodeDetailFields[catName] || [];
|
|
|
+ detailFields.value = fields.map((f) => ({
|
|
|
+ label: f.label,
|
|
|
+ value: (node as any)[f.key] ?? '-',
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearSelection() {
|
|
|
+ selectedNode.value = null;
|
|
|
+ detailFields.value = [];
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 缩放 ====================
|
|
|
+
|
|
|
+ function zoomIn() {
|
|
|
+ const cur = chartInstance?.getOption()?.series?.[0] as any;
|
|
|
+ const z = cur?.zoom ?? 1;
|
|
|
+ chartInstance?.setOption({ series: [{ zoom: Math.min(z * 1.3, 5) }] as any });
|
|
|
+ }
|
|
|
+
|
|
|
+ function zoomOut() {
|
|
|
+ const cur = chartInstance?.getOption()?.series?.[0] as any;
|
|
|
+ const z = cur?.zoom ?? 1;
|
|
|
+ chartInstance?.setOption({ series: [{ zoom: Math.max(z / 1.3, 0.3) }] as any });
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetView() {
|
|
|
+ chartInstance?.setOption({ series: [{ zoom: 1, center: undefined }] as any });
|
|
|
+ clearSelection();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 模式切换 ====================
|
|
|
+
|
|
|
+ /** 切换 绑定模式 / 布点模式;两种模式均保留悬浮高亮与单击详情 */
|
|
|
+ function setMode(m: TopoMode) {
|
|
|
+ mode.value = m;
|
|
|
+ armedSource.value = null;
|
|
|
+ bindVisible.value = false;
|
|
|
+ if (!chartInstance) return;
|
|
|
+ renderTopology(topologyData);
|
|
|
+ if (m === 'relation') {
|
|
|
+ message.info('双击连线解除关系;双击节点并选中另一节点以建立关系');
|
|
|
+ } else {
|
|
|
+ message.info('双击连线解绑或绑定测风装置');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== resize / dispose ====================
|
|
|
+
|
|
|
+ function handleResize() {
|
|
|
+ chartInstance?.resize();
|
|
|
+ }
|
|
|
+
|
|
|
+ function dispose() {
|
|
|
+ resizeObserver?.disconnect();
|
|
|
+ resizeObserver = null;
|
|
|
+ window.removeEventListener('resize', handleResize);
|
|
|
+ chartInstance?.dispose();
|
|
|
+ chartInstance = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ chartRef,
|
|
|
+ mode,
|
|
|
+ setMode,
|
|
|
+ bindVisible,
|
|
|
+ bindPoint,
|
|
|
+ bindDeviceId,
|
|
|
+ confirmBind,
|
|
|
+ closeBindDialog,
|
|
|
+ selectedNode,
|
|
|
+ detailFields,
|
|
|
+ selectedDeptId,
|
|
|
+ setSelectedDept,
|
|
|
+ initChart,
|
|
|
+ loadTopology,
|
|
|
+ zoomIn,
|
|
|
+ zoomOut,
|
|
|
+ resetView,
|
|
|
+ clearSelection,
|
|
|
+ saveTopologyLayout,
|
|
|
+ dispose,
|
|
|
+ };
|
|
|
+}
|