|
|
@@ -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>
|