337 lines
10 KiB
Vue
337 lines
10 KiB
Vue
<template>
|
|
<div class="grid-management-container">
|
|
<!-- Page Header -->
|
|
<div class="page-header">
|
|
<h2>我的网格地图</h2>
|
|
<div class="header-actions">
|
|
<el-button type="primary" @click="refreshData" :loading="loading || isLoadingWorkers" icon="Refresh">
|
|
刷新数据
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="main-content-grid">
|
|
<!-- Grid Map Area -->
|
|
<div class="map-area" v-loading="loading">
|
|
<div v-if="error" class="error-message">
|
|
<el-alert title="地图数据加载失败" :description="error" type="error" show-icon :closable="false" />
|
|
<el-button @click="fetchAllGrids" type="primary" class="retry-button">重试</el-button>
|
|
</div>
|
|
<svg v-else-if="displayGrids.length" :width="svgDimensions.width" :height="svgDimensions.height" class="grid-svg">
|
|
<defs>
|
|
<pattern id="grid-pattern-worker" :width="CELL_SIZE" :height="CELL_SIZE" patternUnits="userSpaceOnUse">
|
|
<path :d="`M ${CELL_SIZE} 0 L 0 0 0 ${CELL_SIZE}`" fill="none" stroke="#e9e9e9" stroke-width="0.5" />
|
|
</pattern>
|
|
</defs>
|
|
<rect :width="svgDimensions.width" :height="svgDimensions.height" fill="url(#grid-pattern-worker)" />
|
|
|
|
<g v-for="grid in displayGrids" :key="grid.id">
|
|
<rect
|
|
:x="grid.displayX * CELL_SIZE"
|
|
:y="grid.displayY * CELL_SIZE"
|
|
:width="CELL_SIZE"
|
|
:height="CELL_SIZE"
|
|
:fill="getGridFillColor(grid)"
|
|
@click="selectGrid(grid)"
|
|
:class="{ 'selected': selectedGrid && selectedGrid.id === grid.id }"
|
|
class="grid-cell"
|
|
/>
|
|
<!-- Border Rendering -->
|
|
<line v-if="getBorder(grid, 'top')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="grid.displayY * CELL_SIZE" class="border-line" />
|
|
<line v-if="getBorder(grid, 'right')" :x1="(grid.displayX + 1) * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
|
<line v-if="getBorder(grid, 'bottom')" :x1="grid.displayX * CELL_SIZE" :y1="(grid.displayY + 1) * CELL_SIZE" :x2="(grid.displayX + 1) * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
|
<line v-if="getBorder(grid, 'left')" :x1="grid.displayX * CELL_SIZE" :y1="grid.displayY * CELL_SIZE" :x2="grid.displayX * CELL_SIZE" :y2="(grid.displayY + 1) * CELL_SIZE" class="border-line" />
|
|
</g>
|
|
</svg>
|
|
<el-empty v-else description="没有可显示的网格数据" />
|
|
</div>
|
|
|
|
<!-- Details Sidebar -->
|
|
<div class="sidebar-area">
|
|
<el-card shadow="never" class="details-card">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span>网格详情</span>
|
|
</div>
|
|
</template>
|
|
<div v-if="selectedGrid" class="details-content">
|
|
<p><strong>网格ID:</strong> {{ selectedGrid.id }}</p>
|
|
<p><strong>城市:</strong> <el-tag :color="getCityColor(selectedGrid.cityName)" effect="light">{{ selectedGrid.cityName }}</el-tag></p>
|
|
<p><strong>区县:</strong> {{ selectedGrid.districtName || 'N/A' }}</p>
|
|
<p><strong>原始坐标:</strong> ({{ selectedGrid.gridX }}, {{ selectedGrid.gridY }})</p>
|
|
<p><strong>是否为障碍物:</strong> <el-tag :type="selectedGrid.isObstacle ? 'danger' : 'success'">{{ selectedGrid.isObstacle ? '是' : '否' }}</el-tag></p>
|
|
<p><strong>描述:</strong> {{ selectedGrid.description || '无' }}</p>
|
|
<el-divider />
|
|
<p><strong>负责人:</strong></p>
|
|
<div v-if="selectedGridWorker">
|
|
<el-tag effect="plain" type="info">
|
|
<el-icon><User /></el-icon>
|
|
{{ selectedGridWorker.name }} ({{ selectedGridWorker.phone }})
|
|
</el-tag>
|
|
</div>
|
|
<div v-else>
|
|
<el-tag type="warning">未分配</el-tag>
|
|
</div>
|
|
</div>
|
|
<el-empty v-else description="请在左侧地图上选择一个网格" />
|
|
</el-card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, computed } from 'vue';
|
|
import { ElMessage } from 'element-plus';
|
|
import type { Grid, UserAccount } from '@/api/types';
|
|
import { getGrids } from '@/api/dashboard';
|
|
import { getAllGridWorkers } from '@/api/personnel';
|
|
import { User } from '@element-plus/icons-vue';
|
|
|
|
const CELL_SIZE = 25;
|
|
const PADDING_CELLS = 2;
|
|
|
|
// --- State ---
|
|
const gridData = ref<Grid[]>([]);
|
|
const allWorkers = ref<UserAccount[]>([]);
|
|
const loading = ref(false);
|
|
const isLoadingWorkers = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const selectedGrid = ref<DisplayGrid | null>(null);
|
|
|
|
// --- Data Fetching & Refresh ---
|
|
const fetchAllGrids = async () => {
|
|
loading.value = true;
|
|
error.value = null;
|
|
selectedGrid.value = null;
|
|
try {
|
|
const response = await getGrids();
|
|
gridData.value = Array.isArray(response) ? response : (response as any).content || [];
|
|
} catch (e: any) {
|
|
error.value = e.message || '获取网格数据时发生未知错误';
|
|
ElMessage.error(error.value);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const fetchAllWorkers = async () => {
|
|
isLoadingWorkers.value = true;
|
|
try {
|
|
allWorkers.value = await getAllGridWorkers();
|
|
} catch (e: any) {
|
|
ElMessage.error('获取网格员列表失败: ' + e.message);
|
|
} finally {
|
|
isLoadingWorkers.value = false;
|
|
}
|
|
};
|
|
|
|
const refreshData = async () => {
|
|
await Promise.all([fetchAllGrids(), fetchAllWorkers()]);
|
|
ElMessage.success('数据已刷新');
|
|
}
|
|
|
|
onMounted(() => {
|
|
refreshData();
|
|
});
|
|
|
|
// --- Computed Properties ---
|
|
const workersByCoord = computed<Map<string, UserAccount>>(() => {
|
|
const map = new Map<string, UserAccount>();
|
|
allWorkers.value.forEach(worker => {
|
|
if (worker.gridX !== null && worker.gridY !== null) {
|
|
map.set(`${worker.gridX},${worker.gridY}`, worker);
|
|
}
|
|
});
|
|
return map;
|
|
});
|
|
|
|
const selectedGridWorker = computed<UserAccount | undefined>(() => {
|
|
if (!selectedGrid.value || !allWorkers.value.length) {
|
|
return undefined;
|
|
}
|
|
return allWorkers.value.find(
|
|
worker => worker.gridX === selectedGrid.value?.gridX && worker.gridY === selectedGrid.value?.gridY
|
|
);
|
|
});
|
|
|
|
// --- City Color Logic ---
|
|
const cityColors = ref<Map<string, string>>(new Map());
|
|
const getCityColor = (cityName: string): string => {
|
|
if (!cityColors.value.has(cityName)) {
|
|
let hash = 0;
|
|
for (let i = 0; i < cityName.length; i++) {
|
|
hash = cityName.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const color = `hsl(${hash % 360}, 80%, 85%)`;
|
|
cityColors.value.set(cityName, color);
|
|
}
|
|
return cityColors.value.get(cityName)!;
|
|
};
|
|
|
|
const getGridFillColor = (grid: Grid): string => {
|
|
if (grid.isObstacle) {
|
|
return '#000000';
|
|
}
|
|
if (workersByCoord.value.has(`${grid.gridX},${grid.gridY}`)) {
|
|
return '#FF0000';
|
|
}
|
|
return getCityColor(grid.cityName);
|
|
};
|
|
|
|
// --- Display Logic ---
|
|
interface DisplayGrid extends Grid {
|
|
displayX: number;
|
|
displayY: number;
|
|
}
|
|
|
|
const displayGrids = computed<DisplayGrid[]>(() => {
|
|
if (!gridData.value.length) return [];
|
|
|
|
// Determine the top-left corner of the entire map to normalize coordinates
|
|
const minX = Math.min(...gridData.value.map(g => g.gridX));
|
|
const minY = Math.min(...gridData.value.map(g => g.gridY));
|
|
|
|
// Render all grids based on their absolute positions, normalized to start at (0,0)
|
|
return gridData.value.map(grid => ({
|
|
...grid,
|
|
displayX: grid.gridX - minX,
|
|
displayY: grid.gridY - minY,
|
|
}));
|
|
});
|
|
|
|
const gridMap = computed(() => {
|
|
const map = new Map<string, DisplayGrid>();
|
|
displayGrids.value.forEach(grid => {
|
|
map.set(`${grid.displayX},${grid.displayY}`, grid);
|
|
});
|
|
return map;
|
|
});
|
|
|
|
const svgDimensions = computed(() => {
|
|
if (!displayGrids.value.length) return { width: 0, height: 0 };
|
|
const maxX = Math.max(...displayGrids.value.map(g => g.displayX));
|
|
const maxY = Math.max(...displayGrids.value.map(g => g.displayY));
|
|
return {
|
|
width: (maxX + 1) * CELL_SIZE,
|
|
height: (maxY + 1) * CELL_SIZE,
|
|
};
|
|
});
|
|
|
|
const getBorder = (grid: DisplayGrid, side: 'top' | 'right' | 'bottom' | 'left'): boolean => {
|
|
const { displayX, displayY, cityName } = grid;
|
|
let neighbor: DisplayGrid | undefined;
|
|
switch (side) {
|
|
case 'top':
|
|
neighbor = gridMap.value.get(`${displayX},${displayY - 1}`);
|
|
break;
|
|
case 'right':
|
|
neighbor = gridMap.value.get(`${displayX + 1},${displayY}`);
|
|
break;
|
|
case 'bottom':
|
|
neighbor = gridMap.value.get(`${displayX},${displayY + 1}`);
|
|
break;
|
|
case 'left':
|
|
neighbor = gridMap.value.get(`${displayX - 1},${displayY}`);
|
|
break;
|
|
}
|
|
return !neighbor || neighbor.cityName !== cityName;
|
|
};
|
|
|
|
const selectGrid = (grid: DisplayGrid) => {
|
|
selectedGrid.value = grid;
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.grid-management-container {
|
|
padding: 20px;
|
|
background-color: #f7f8fa;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.page-header h2 {
|
|
margin: 0;
|
|
font-size: 24px;
|
|
}
|
|
|
|
.main-content-grid {
|
|
display: flex;
|
|
gap: 20px;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.map-area {
|
|
flex: 3;
|
|
background-color: #fff;
|
|
border-radius: 8px;
|
|
padding: 10px;
|
|
overflow: auto;
|
|
border: 1px solid #e9e9e9;
|
|
}
|
|
|
|
.sidebar-area {
|
|
flex: 1;
|
|
min-width: 300px;
|
|
}
|
|
|
|
.details-card .card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.grid-svg {
|
|
display: block;
|
|
}
|
|
|
|
.grid-cell {
|
|
stroke: #fff;
|
|
stroke-width: 1;
|
|
transition: filter 0.2s ease-in-out;
|
|
}
|
|
|
|
.grid-cell:hover {
|
|
filter: brightness(0.9);
|
|
}
|
|
|
|
.grid-cell.selected {
|
|
stroke: #ff4d4f;
|
|
stroke-width: 2;
|
|
}
|
|
|
|
.border-line {
|
|
stroke: #333;
|
|
stroke-width: 1.5;
|
|
}
|
|
|
|
.error-message {
|
|
padding: 20px;
|
|
}
|
|
|
|
.retry-button {
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.details-content p {
|
|
margin: 0 0 12px;
|
|
color: #606266;
|
|
}
|
|
|
|
.details-content p strong {
|
|
color: #303133;
|
|
margin-right: 8px;
|
|
}
|
|
</style> |