Files
Environment-Monitoring-System/ems-frontend/ems-monitoring-system/src/components/GridMap.vue
ChuXun 02a830145e 1
2025-10-25 19:18:43 +08:00

333 lines
11 KiB
Vue

<template>
<div class="grid-map-container">
<div class="map-header">
<h3>环境监测网格地图</h3>
<div class="map-controls">
<el-select v-model="selectedCity" placeholder="选择城市" @change="handleCityChange" size="small">
<el-option v-for="city in cities" :key="city" :label="city" :value="city" />
</el-select>
<el-button type="primary" size="small" :icon="Loading" :loading="loading" @click="refreshData">
刷新数据
</el-button>
</div>
</div>
<div class="map-content">
<div class="map-container" ref="mapContainer">
<div v-if="loading" class="loading-overlay">
<el-icon class="loading-icon" :size="32"><Loading /></el-icon>
<div class="loading-text">加载中...</div>
</div>
</div>
<div class="map-legend">
<div v-for="city in Object.keys(cityColorPalette)" :key="city" class="legend-item">
<div class="legend-color" :style="{ backgroundColor: cityColorPalette[city] }"></div>
<span>{{ city }}</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #333333;"></div>
<span>障碍区域</span>
</div>
</div>
<!-- 网格编辑/详情一体化对话框 -->
<el-dialog
v-model="gridEditDialogVisible"
:title="editingGrid ? `编辑网格 (${editingGrid.gridX}, ${editingGrid.gridY})` : '网格详情'"
width="600px"
destroy-on-close
>
<el-form v-if="editingGrid" :model="editFormData" label-width="120px">
<el-descriptions :column="1" border class="detail-view" v-if="!isEditMode">
<el-descriptions-item label="城市">{{ editingGrid.cityName }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ editingGrid.districtName || '未指定' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="editingGrid.isObstacle ? 'danger' : currentWorkerInDialog ? 'success' : 'warning'" effect="dark">
{{ editingGrid.isObstacle ? '障碍区域' : currentWorkerInDialog ? '已分配' : '未分配' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述">{{ editFormData.description || '无' }}</el-descriptions-item>
<el-descriptions-item label="网格员">
<div v-if="currentWorkerInDialog">
<strong>{{ currentWorkerInDialog.name }}</strong> (ID: {{ currentWorkerInDialog.id }})
</div>
<div v-else>未分配</div>
</el-descriptions-item>
</el-descriptions>
<div v-if="isEditMode">
<el-form-item label="描述">
<el-input v-model="editFormData.description" type="textarea" :rows="3" placeholder="请输入网格描述" />
</el-form-item>
<el-form-item label="是否为障碍物">
<el-switch v-model="editFormData.isObstacle" />
</el-form-item>
<div v-if="!editFormData.isObstacle">
<el-divider>网格员分配</el-divider>
<el-form-item label="分配网格员">
<el-select v-model="editFormData.workerId" placeholder="选择或搜索网格员" clearable filterable style="width: 100%;">
<el-option
v-for="worker in availableGridWorkers"
:key="worker.id"
:label="`${worker.name} (ID: ${worker.id})`"
:value="worker.id"
/>
</el-select>
</el-form-item>
</div>
</div>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="isEditMode = !isEditMode">{{ isEditMode ? '返回详情' : '编辑网格' }}</el-button>
<el-button @click="gridEditDialogVisible = false">关闭</el-button>
<el-button v-if="isEditMode" type="primary" @click="handleUpdateGrid" :loading="loading">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import {
getGrids,
getGridWorkers,
updateGrid,
assignGridWorkerByCoordinates,
removeGridWorkerByCoordinates
} from '@/api/grid';
import type { GridData, GridWorker } from '@/api/grid';
import {
ElMessage,
ElDialog,
ElDescriptions,
ElDescriptionsItem,
ElButton,
ElDivider,
ElTag,
ElSelect,
ElOption,
ElForm,
ElFormItem,
ElInput,
ElSwitch,
ElAlert
} from 'element-plus';
import { Loading } from '@element-plus/icons-vue';
const loading = ref(false);
const selectedCity = ref<string>('北京市');
const cities = ref<string[]>(['北京市']);
const mapContainer = ref<HTMLDivElement | null>(null);
const gridData = ref<GridData[]>([]);
const gridWorkers = ref<GridWorker[]>([]);
const availableGridWorkers = ref<GridWorker[]>([]);
const gridEditDialogVisible = ref(false);
const editingGrid = ref<GridData | null>(null);
const isEditMode = ref(false);
const editFormData = ref({
description: '',
isObstacle: false,
workerId: null as number | string | null,
});
const currentWorkerInDialog = computed(() => {
if (!editingGrid.value) return null;
return gridWorkers.value.find(w => w.gridX === editingGrid.value!.gridX && w.gridY === editingGrid.value!.gridY) || null;
});
let gridSize = 20;
const cityColorPalette: Record<string, string> = {};
const getCityColor = (cityName: string): string => {
if (!cityColorPalette[cityName]) {
const predefinedColors = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'];
const allCityNames = [...new Set(gridData.value.map(g => g.cityName))];
const cityIndex = allCityNames.indexOf(cityName);
cityColorPalette[cityName] = predefinedColors[cityIndex % predefinedColors.length];
}
return cityColorPalette[cityName];
};
const loadData = async () => {
loading.value = true;
try {
const [grids, workers] = await Promise.all([
getGrids(),
getGridWorkers()
]);
if (Array.isArray(grids)) {
gridData.value = grids;
const uniqueCities = [...new Set(grids.map(g => g.cityName))];
if (uniqueCities.length > 0) {
cities.value = uniqueCities;
selectedCity.value = uniqueCities[0];
}
}
if (Array.isArray(workers)) {
gridWorkers.value = workers;
}
renderMap();
} catch (error) {
console.error('加载数据失败:', error);
ElMessage.error('加载数据失败,请检查后端服务');
} finally {
loading.value = false;
}
};
const fetchAvailableGridWorkers = async () => {
try {
const response = await getGridWorkers({ unassigned: true });
availableGridWorkers.value = Array.isArray(response) ? response : [];
} catch (error) {
console.error('获取可用网格员数据失败:', error);
ElMessage.error('获取可用网格员列表失败');
}
};
const renderMap = () => {
const container = mapContainer.value;
if (!container || !gridData.value || gridData.value.length === 0) {
if (container) container.innerHTML = '<div class="empty-state">没有可显示的网格数据</div>';
return;
}
container.innerHTML = '';
const allX = gridData.value.map(g => g.gridX);
const allY = gridData.value.map(g => g.gridY);
const minX = Math.min(...allX);
const maxX = Math.max(...allX);
const minY = Math.min(...allY);
const maxY = Math.max(...allY);
const worldWidth = maxX - minX + 1;
const worldHeight = maxY - minY + 1;
gridSize = Math.max(5, Math.floor(Math.min(container.clientWidth / worldWidth, container.clientHeight / worldHeight)));
const svgWidth = worldWidth * gridSize;
const svgHeight = worldHeight * gridSize;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', `${svgWidth}px`);
svg.setAttribute('height', `${svgHeight}px`);
svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
gridData.value.forEach(grid => {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', `${(grid.gridX - minX) * gridSize}`);
rect.setAttribute('y', `${(grid.gridY - minY) * gridSize}`);
rect.setAttribute('width', `${gridSize}`);
rect.setAttribute('height', `${gridSize}`);
rect.setAttribute('fill', grid.isObstacle ? '#333333' : getCityColor(grid.cityName));
rect.setAttribute('stroke-width', '0.5');
rect.setAttribute('stroke', '#fff');
rect.setAttribute('cursor', 'pointer');
rect.addEventListener('click', () => showGridDialog(grid));
svg.appendChild(rect);
});
container.appendChild(svg);
};
const showGridDialog = async (grid: GridData) => {
isEditMode.value = false;
editingGrid.value = grid;
const currentWorker = gridWorkers.value.find(w => w.gridX === grid.gridX && w.gridY === grid.gridY);
editFormData.value = {
description: grid.description || '',
isObstacle: grid.isObstacle,
workerId: currentWorker ? currentWorker.id : '',
};
await fetchAvailableGridWorkers();
gridEditDialogVisible.value = true;
};
const handleUpdateGrid = async () => {
if (!editingGrid.value) return;
loading.value = true;
const grid = editingGrid.value;
const { isObstacle, description, workerId } = editFormData.value;
const newWorkerId = workerId ? Number(workerId) : null;
try {
await updateGrid({ ...grid, isObstacle, description });
const workerAssignmentChanged = (currentWorkerInDialog.value?.id ?? null) !== newWorkerId;
if (isObstacle && currentWorkerInDialog.value) {
await removeGridWorkerByCoordinates(grid.gridX, grid.gridY);
} else if (!isObstacle && workerAssignmentChanged) {
if (newWorkerId) {
await assignGridWorkerByCoordinates(grid.gridX, grid.gridY, newWorkerId);
} else if (currentWorkerInDialog.value) {
await removeGridWorkerByCoordinates(grid.gridX, grid.gridY);
}
}
ElMessage.success('网格信息更新成功');
gridEditDialogVisible.value = false;
await loadData();
} catch (error: any) {
ElMessage.error('更新网格失败: ' + (error?.response?.data?.message || error.message));
} finally {
loading.value = false;
}
};
const refreshData = () => loadData();
const handleCityChange = () => { /* Future implementation for city-specific view */ };
onMounted(() => {
loadData();
window.addEventListener('resize', renderMap);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', renderMap);
});
</script>
<style scoped>
.grid-map-container {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
border-radius: 8px;
}
.map-header {
padding: 15px 20px;
border-bottom: 1px solid #ebeef5;
}
.map-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 15px;
overflow: hidden;
}
.map-container {
flex: 1;
position: relative;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: auto;
}
.map-legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
padding-top: 15px;
}
.legend-item { display: flex; align-items: center; }
.legend-color { width: 16px; height: 16px; margin-right: 8px; border-radius: 4px; }
.dialog-footer { text-align: right; }
.detail-view { margin-bottom: 20px; }
</style>