333 lines
11 KiB
Vue
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> |