Browse Source

监控与控制中心

liuq 2 months ago
parent
commit
0bf5ccede5

+ 1 - 1
frontend/src/api/resource.ts

@@ -29,7 +29,7 @@ export interface Device {
   DeviceType: string;
   Status: string;
   RatedPower: number;
-  LocationID?: string;
+  LocationID?: string | null;
   SourceID?: string;
   MeteringMode?: string;
   AttributeMapping?: Record<string, string>;

+ 11 - 11
frontend/src/stores/permission.ts

@@ -41,6 +41,17 @@ export const usePermissionStore = defineStore('permission', {
 
         // Mock Menus to match the requested style/structure
         const mockMenus = [
+          {
+            path: '/monitor',
+            name: '监控与控制中心',
+            component: 'Layout',
+            icon: 'IconDashboard',
+            children: [
+              { path: 'dashboard', name: '综合态势大屏', component: 'monitor/Dashboard' },
+              { path: 'device-control', name: '设备集控视图', component: 'monitor/DeviceControl' },
+              { path: 'alarm', name: '实时告警台', component: 'monitor/AlarmConsole' },
+            ]
+          },
           {
             path: '/resource',
             name: '资源与物联中心',
@@ -53,17 +64,6 @@ export const usePermissionStore = defineStore('permission', {
               { path: 'topology', name: '空间拓扑管理', component: 'resource/Topology' },
             ]
           },
-          {
-            path: '/monitor',
-            name: '监控与控制中心',
-            component: 'Layout',
-            icon: 'IconDashboard',
-            children: [
-              { path: 'screen', name: '综合态势大屏', component: 'monitor/Dashboard' },
-              { path: 'device-control', name: '设备集控视图', component: 'monitor/DeviceControl' },
-              { path: 'alarm', name: '实时告警台', component: 'monitor/AlarmConsole' },
-            ]
-          },
           {
              path: '/analysis',
              name: '能耗分析引擎',

+ 99 - 4
frontend/src/views/resource/CleaningTemplate.vue

@@ -2,10 +2,24 @@
   <div class="page-container">
     <div class="header">
       <a-typography-title :heading="4" style="margin: 0">设备导入清洗公式模板</a-typography-title>
-      <a-button type="primary" @click="openDialog()">
-        <template #icon><icon-plus /></template>
-        新增模板
-      </a-button>
+      <a-space>
+        <a-upload :show-file-list="false" :custom-request="handleImport" accept=".xlsx">
+            <template #upload-button>
+                <a-button>
+                    <template #icon><icon-upload /></template>
+                    导入
+                </a-button>
+            </template>
+        </a-upload>
+        <a-button @click="handleExport">
+            <template #icon><icon-download /></template>
+            导出
+        </a-button>
+        <a-button type="primary" @click="openDialog()">
+            <template #icon><icon-plus /></template>
+            新增模板
+        </a-button>
+      </a-space>
     </div>
 
     <a-card :bordered="false">
@@ -79,8 +93,10 @@
 </template>
 
 <script lang="ts" setup>
+import * as XLSX from 'xlsx';
 import { ref, onMounted, reactive } from 'vue';
 import { Message, Modal } from '@arco-design/web-vue';
+import { IconPlus, IconUpload, IconDownload } from '@arco-design/web-vue/es/icon';
 import { DEVICE_TYPES } from '@/constants/device';
 import { getCleaningTemplates, createCleaningTemplate, updateCleaningTemplate, deleteCleaningTemplate, type CleaningTemplate } from '@/api/cleaningTemplate';
 import dayjs from 'dayjs';
@@ -128,6 +144,85 @@ const formatTime = (ts: number | undefined) => {
     return dayjs(ts).format('YYYY-MM-DD HH:mm:ss');
 }
 
+const handleExport = () => {
+    const data = tableData.value.map(item => ({
+        '模板名称': item.name,
+        '设备类型': item.equipmentType,
+        '描述': item.description,
+        '清洗公式配置': JSON.stringify(item.formula)
+    }));
+    const ws = XLSX.utils.json_to_sheet(data);
+    const wb = XLSX.utils.book_new();
+    XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
+    XLSX.writeFile(wb, `清洗公式模板_${dayjs().format('YYYYMMDDHHmmss')}.xlsx`);
+};
+
+const handleImport = async (option: any) => {
+    const { fileItem } = option;
+    const file = fileItem.file;
+    const reader = new FileReader();
+    reader.onload = async (e) => {
+        try {
+            const data = e.target?.result;
+            const workbook = XLSX.read(data, { type: 'binary' });
+            const sheetName = workbook.SheetNames[0];
+            const sheet = workbook.Sheets[sheetName];
+            const json: any[] = XLSX.utils.sheet_to_json(sheet);
+            
+            let successCount = 0;
+            let failCount = 0;
+
+            loading.value = true;
+            for (const item of json) {
+                try {
+                     const name = item['模板名称'] || item['name'] || item['Name'];
+                     const type = item['设备类型'] || item['equipmentType'] || item['EquipmentType'];
+                     const desc = item['描述'] || item['description'] || item['Description'];
+                     const formulaRaw = item['清洗公式配置'] || item['formula'] || item['Formula'];
+
+                     if (!name || !type) {
+                         failCount++;
+                         continue;
+                     }
+
+                     let formulaObj = {};
+                     if (typeof formulaRaw === 'string') {
+                         try {
+                             formulaObj = JSON.parse(formulaRaw);
+                         } catch (e) {
+                             console.warn('Invalid JSON in formula', formulaRaw);
+                             formulaObj = {}; 
+                         }
+                     } else if (typeof formulaRaw === 'object') {
+                         formulaObj = formulaRaw;
+                     }
+
+                     const payload: CleaningTemplate = {
+                        name: name,
+                        equipmentType: type,
+                        description: desc || '',
+                        formula: formulaObj
+                    };
+                    
+                    await createCleaningTemplate(payload);
+                    successCount++;
+                } catch (err) {
+                    console.error("Import row error", err);
+                    failCount++;
+                }
+            }
+            Message.success(`导入完成: 成功 ${successCount} 条` + (failCount > 0 ? `, 失败 ${failCount} 条` : ''));
+            fetchData();
+        } catch (error) {
+            console.error(error);
+            Message.error('文件解析失败');
+        } finally {
+            loading.value = false;
+        }
+    };
+    reader.readAsBinaryString(file);
+};
+
 const openDialog = (record?: CleaningTemplate) => {
   if (record) {
     form.id = record.id || '';

+ 160 - 10
frontend/src/views/resource/Topology.vue

@@ -5,9 +5,22 @@
       <a-col :span="6" class="left-panel">
         <div class="panel-header">
           <span>空间结构</span>
-          <a-button type="text" @click="openLocDialog('add')">
-            <template #icon><icon-plus /></template>
-          </a-button>
+          <a-space>
+             <a-tooltip content="导出完整空间结构">
+                <a-button type="text" @click="handleExport">
+                  <template #icon><icon-download /></template>
+                </a-button>
+             </a-tooltip>
+             <a-tooltip content="导入空间 (需以园区为单位)">
+                <a-button type="text" @click="handleImportClick">
+                  <template #icon><icon-upload /></template>
+                </a-button>
+             </a-tooltip>
+             <a-button type="text" @click="openLocDialog('add')">
+                <template #icon><icon-plus /></template>
+             </a-button>
+          </a-space>
+          <input type="file" ref="fileInput" accept=".xlsx, .xls" style="display: none" @change="handleImportFile" />
         </div>
         <a-input v-model="filterText" placeholder="搜索节点..." style="margin-bottom: 10px" allow-clear />
         <a-tree
@@ -104,9 +117,10 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted, watch } from 'vue';
 import { Message, Modal } from '@arco-design/web-vue';
+import * as XLSX from 'xlsx';
 import { 
   IconPlus, IconEdit, IconDelete, IconCommand, IconHome, IconLayers, IconCommon,
-  IconCompass, IconApps 
+  IconCompass, IconApps, IconDownload, IconUpload
 } from '@arco-design/web-vue/es/icon';
 import { getLocations, createLocation, updateLocation, deleteLocation, getDevices, updateDevice } from '@/api/resource';
 import type { Location, Device } from '@/api/resource';
@@ -127,6 +141,7 @@ const deviceDialogVisible = ref(false);
 const unassignedDevices = ref<Device[]>([]);
 const selectedDeviceKeys = ref<string[]>([]);
 const rowSelection = { type: 'checkbox', showCheckedAll: true };
+const fileInput = ref<HTMLInputElement | null>(null);
 
 // --- Hierarchy Rules ---
 const hierarchyRules: Record<string, string[]> = {
@@ -139,6 +154,15 @@ const hierarchyRules: Record<string, string[]> = {
   'PUBLIC': []
 };
 
+const typePriority: Record<string, number> = {
+  'PARK': 1,
+  'BUILDING': 2,
+  'FLOOR': 3,
+  'ROOM': 4,
+  'ROAD': 4,
+  'PUBLIC': 5
+};
+
 const typeLabels: Record<string, string> = {
   'PARK': '园区',
   'BUILDING': '建筑',
@@ -170,6 +194,126 @@ const loadTree = async () => {
   treeData.value = buildTree(list);
 };
 
+// --- Export / Import Logic ---
+
+const handleExport = async () => {
+    try {
+        const res = await getLocations();
+        const list = (res as any).data || res;
+        
+        // 导出包含 ID 和 ParentID 以支持无缝还原
+        const dataToExport = list.map((item: Location) => ({
+            ID: item.ID,
+            Name: item.Name,
+            Type: item.Type,
+            ParentID: item.ParentID || ''
+        }));
+
+        const ws = XLSX.utils.json_to_sheet(dataToExport);
+        const wb = XLSX.utils.book_new();
+        XLSX.utils.book_append_sheet(wb, ws, "Locations");
+        XLSX.writeFile(wb, "space_export.xlsx");
+        Message.success('导出成功');
+    } catch (e) {
+        console.error(e);
+        Message.error('导出失败');
+    }
+};
+
+const handleImportClick = () => {
+    if (fileInput.value) fileInput.value.value = ''; // 重置以允许重复选择同名文件
+    fileInput.value?.click();
+};
+
+const handleImportFile = (event: Event) => {
+    const target = event.target as HTMLInputElement;
+    if (!target.files || target.files.length === 0) return;
+    
+    const file = target.files[0];
+    const reader = new FileReader();
+    
+    reader.onload = async (e) => {
+        try {
+            const data = e.target?.result;
+            const workbook = XLSX.read(data, { type: 'array' });
+            const firstSheetName = workbook.SheetNames[0];
+            const worksheet = workbook.Sheets[firstSheetName];
+            const jsonData = XLSX.utils.sheet_to_json(worksheet) as any[];
+
+            if (jsonData.length === 0) {
+                Message.warning('文件为空');
+                return;
+            }
+
+            // --- 校验逻辑:必须以园区为单位 ---
+            const fileIdMap = new Set(jsonData.map(item => item.ID));
+            // 找出本次导入中没有父级(或父级不在文件内)的节点,这些就是本次导入的"根"
+            const fileRoots = jsonData.filter(item => {
+                return !item.ParentID || !fileIdMap.has(item.ParentID);
+            });
+
+            // 检查这些"根"是否都是 PARK 类型
+            const invalidRoots = fileRoots.filter(root => root.Type !== 'PARK');
+            if (invalidRoots.length > 0) {
+                const names = invalidRoots.map(r => r.Name).join(', ');
+                Message.error(`导入验证失败:检测到孤立节点 [${names}]。导入必须以园区 (PARK) 为完整单元,不能单独导入楼层或房间。`);
+                return;
+            }
+
+            // --- 准备导入 ---
+            // 获取现有数据,用于判断是 Create 还是 Update
+            let existingIdMap = new Set<string>();
+            try {
+                 const res = await getLocations();
+                 const list = (res as any).data || res;
+                 list.forEach((l: Location) => existingIdMap.add(l.ID));
+            } catch(e) { /* ignore */ }
+
+            // 按层级排序 (确保先创建父节点)
+            jsonData.sort((a, b) => {
+                const pA = typePriority[a.Type] || 99;
+                const pB = typePriority[b.Type] || 99;
+                return pA - pB;
+            });
+
+            let successCount = 0;
+            let failCount = 0;
+
+            for (const item of jsonData) {
+                if (!item.Name || !item.Type) continue;
+
+                // 显式传递 ID 以保证数据一致性
+                const payload: any = {
+                    ID: item.ID,
+                    Name: item.Name,
+                    Type: item.Type,
+                    ParentID: item.ParentID || null 
+                };
+
+                try {
+                    if (existingIdMap.has(item.ID)) {
+                        await updateLocation(item.ID, payload);
+                    } else {
+                        await createLocation(payload);
+                    }
+                    successCount++;
+                } catch (err) {
+                    console.error(`Import failed for ${item.Name}`, err);
+                    failCount++;
+                }
+            }
+            
+            Message.success(`导入完成: 成功 ${successCount},失败 ${failCount}`);
+            loadTree();
+            
+        } catch (e) {
+            console.error(e);
+            Message.error('文件解析失败');
+        }
+    };
+    reader.readAsArrayBuffer(file);
+};
+
 const buildTree = (list: Location[]) => {
     const map = new Map<string, Location>();
     const roots: Location[] = [];
@@ -310,12 +454,18 @@ const addDevicesToNode = async () => {
     await loadNodeDevices();
 };
 
-const removeDevice = async (row: Device) => {
-    await updateDevice(row.ID, { LocationID: '' }); 
-    Message.success('已移出');
-    if (currentNode.value) {
-        await loadNodeDevices();
-    }
+const removeDevice = (row: Device) => {
+    Modal.confirm({
+        title: '警告',
+        content: `确定将设备 [${row.Name}] 移出当前空间节点吗?`,
+        onOk: async () => {
+            await updateDevice(row.ID, { LocationID: null });
+            Message.success('已移出');
+            if (currentNode.value) {
+                await loadNodeDevices();
+            }
+        }
+    });
 };
 
 </script>