feat(dashboard): 添加首页地图展示功能

- 在 Dashboard 组件中集成锡林郭勒盟区域地图
- 实现地图数据接口和区域详情接口
- 添加地图交互功能,支持点击和悬停事件
- 更新开发计划和需求文档,增加地图展示功能
This commit is contained in:
2025-08-20 20:34:52 +08:00
parent fdc58aa3a2
commit 5ff7d38904
12 changed files with 1537 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
import {
__export
} from "./chunk-2GTGKKMZ.js";
} from "./chunk-5WWUZCGV.js";
// node_modules/tslib/tslib.es6.js
var extendStatics = function(d, b) {

View File

@@ -0,0 +1,247 @@
<template>
<div class="region-detail-overlay" v-if="visible" @click="closeOverlay">
<div class="region-detail-card" @click.stop>
<div class="card-header">
<h2>{{ regionData.region?.name }} 详情</h2>
<button class="close-button" @click="closeOverlay">×</button>
</div>
<div class="card-content">
<div class="region-stats">
<div class="stat-item">
<div class="stat-label">牛只数量</div>
<div class="stat-value">{{ regionData.region?.cattle_count || 0 }}</div>
</div>
<div class="stat-item">
<div class="stat-label">牧场数量</div>
<div class="stat-value">{{ regionData.region?.farm_count || 0 }}</div>
</div>
<div class="stat-item">
<div class="stat-label">产值</div>
<div class="stat-value">¥{{ formatNumber(regionData.region?.output_value || 0) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">趋势</div>
<div class="stat-value" :class="regionData.region?.trend">
{{ regionData.region?.trend === 'up' ? '↑ 上升' : '↓ 下降' }}
</div>
</div>
</div>
<div class="farms-section" v-if="regionData.farms && regionData.farms.length > 0">
<h3>牧场列表</h3>
<div class="farms-list">
<div
class="farm-item"
v-for="farm in regionData.farms"
:key="farm.id"
>
<div class="farm-name">{{ farm.name }}</div>
<div class="farm-details">
<span>牛只: {{ farm.cattle_count }}</span>
<span>产值: ¥{{ formatNumber(farm.output_value) }}</span>
</div>
</div>
</div>
</div>
<div class="no-farms" v-else>
<p>暂无牧场数据</p>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, defineProps, defineEmits } from 'vue';
export default {
name: 'RegionDetail',
props: {
visible: {
type: Boolean,
default: false
},
regionData: {
type: Object,
default: () => ({})
}
},
emits: ['close'],
setup(props, { emit }) {
const closeOverlay = () => {
emit('close');
};
const formatNumber = (num) => {
if (num >= 100000000) {
return (num / 100000000).toFixed(2) + '亿';
}
if (num >= 10000) {
return (num / 10000).toFixed(2) + '万';
}
return num.toLocaleString();
};
return {
closeOverlay,
formatNumber
};
}
};
</script>
<style scoped>
.region-detail-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.region-detail-card {
background: linear-gradient(135deg, #0f2027, #20555d);
border-radius: 12px;
width: 600px;
max-width: 90%;
max-height: 90%;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.card-header {
padding: 20px;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.card-header h2 {
margin: 0;
font-size: 24px;
color: #4CAF50;
}
.close-button {
background: none;
border: none;
color: #fff;
font-size: 28px;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.3s;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.card-content {
padding: 20px;
overflow-y: auto;
max-height: calc(90vh - 100px);
}
.region-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.stat-item {
background: rgba(255, 255, 255, 0.05);
padding: 15px;
border-radius: 8px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-label {
font-size: 14px;
color: #ccc;
margin-bottom: 8px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #4CAF50;
}
.stat-value.up {
color: #4CAF50;
}
.stat-value.down {
color: #F44336;
}
.farms-section h3 {
color: #4CAF50;
margin-top: 0;
margin-bottom: 15px;
font-size: 18px;
}
.farms-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.farm-item {
background: rgba(255, 255, 255, 0.05);
padding: 15px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.farm-name {
font-weight: bold;
margin-bottom: 8px;
color: #fff;
}
.farm-details {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #ccc;
}
.no-farms {
text-align: center;
color: #ccc;
padding: 30px 0;
}
@media (max-width: 768px) {
.region-stats {
grid-template-columns: 1fr;
}
.farm-details {
flex-direction: column;
gap: 5px;
}
}
</style>

View File

@@ -5,18 +5,27 @@
class="map-canvas"
:style="{ width: '100%', height: height + 'px' }"
></div>
<RegionDetail
:visible="showRegionDetail"
:region-data="selectedRegionData"
@close="closeRegionDetail"
/>
</div>
</template>
<script>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as turf from '@turf/turf';
import RegionDetail from './RegionDetail.vue';
import { fetchRegionDetail } from '@/services/dashboard.js';
export default {
name: 'ThreeDMap',
components: {
RegionDetail
},
props: {
height: {
type: Number,
@@ -35,8 +44,12 @@ export default {
},
setup(props) {
const mapContainer = ref(null);
const showRegionDetail = ref(false);
const selectedRegionData = ref({});
let scene, camera, renderer, controls;
let animationId = null;
let landmarks = [];
let raycaster, mouse;
// 初始化3D场景
const initScene = () => {
@@ -78,6 +91,14 @@ export default {
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// 初始化射线检测器
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
// 添加事件监听
renderer.domElement.addEventListener('click', onMouseClick, false);
renderer.domElement.addEventListener('mousemove', onMouseMove, false);
// 创建地形和地标
createTerrain();
createLandmarks();
@@ -91,7 +112,7 @@ export default {
// 创建一个简单的平面作为地形基础
const geometry = new THREE.PlaneGeometry(2000, 2000, 50, 50);
const material = new THREE.MeshStandardMaterial({
color: 0x4CAF50,
color: 0x2c5364,
wireframe: false,
transparent: true,
opacity: 0.7
@@ -99,7 +120,7 @@ export default {
const terrain = new THREE.Mesh(geometry, material);
terrain.rotation.x = -Math.PI / 2;
terrain.position.y = -10;
terrain.position.y = -20;
terrain.receiveShadow = true;
scene.add(terrain);
@@ -115,53 +136,155 @@ export default {
// 创建地标
const createLandmarks = () => {
// 创建锡林郭勒盟主要旗县的地标
const locations = [
{ name: '锡林浩特市', position: [0, 0, 0], color: 0x2196F3 },
{ name: '东乌珠穆沁旗', position: [-200, 0, 100], color: 0xFF9800 },
{ name: '西乌珠穆沁旗', position: [200, 0, 100], color: 0xF44336 },
{ name: '镶黄旗', position: [-100, 0, -150], color: 0x9C27B0 },
{ name: '正镶白旗', position: [100, 0, -150], color: 0x4CAF50 }
];
// 清除现有的地标
landmarks.forEach(landmark => {
scene.remove(landmark.mesh);
scene.remove(landmark.label);
});
landmarks = [];
locations.forEach(location => {
// 根据传入的地图数据创建地标
props.mapData.forEach((location, index) => {
// 计算位置(基于锡林浩特市为中心点)
const x = (location.coordinates[0] - props.center[0]) * 5000;
const z = (props.center[1] - location.coordinates[1]) * 5000;
// 根据牛只数量确定地标大小
const size = Math.max(20, Math.min(50, (location.cattle_count || location.cattleCount) / 1000));
const height = Math.max(30, Math.min(100, (location.cattle_count || location.cattleCount) / 500));
// 创建地标圆柱体
const geometry = new THREE.CylinderGeometry(20, 20, 50, 32);
const geometry = new THREE.CylinderGeometry(size, size, height, 32);
const material = new THREE.MeshStandardMaterial({
color: location.color,
color: getColorByCattleCount(location.cattle_count || location.cattleCount),
transparent: true,
opacity: 0.8
});
const cylinder = new THREE.Mesh(geometry, material);
cylinder.position.set(...location.position);
cylinder.position.y = 25;
cylinder.position.set(x, height/2 - 20, z);
cylinder.castShadow = true;
cylinder.userData = { location }; // 保存位置信息用于点击检测
scene.add(cylinder);
// 添加地标名称
addLabel(location.name, location.position, location.color);
// 添加地标名称和数据
const label = addLabel(
`${location.name}\n牛只: ${location.cattle_count || location.cattleCount}\n牧场: ${location.farm_count || location.farmCount}`,
[x, height + 20, z],
getColorByCattleCount(location.cattle_count || location.cattleCount)
);
landmarks.push({
mesh: cylinder,
label: label,
data: location
});
});
};
// 根据牛只数量获取颜色
const getColorByCattleCount = (count) => {
if (count > 20000) return 0x4CAF50; // 绿色 - 数量多
if (count > 15000) return 0xFFEB3B; // 黄色 - 数量中等
return 0xF44336; // 红色 - 数量较少
};
// 添加标签
const addLabel = (text, position, color) => {
// 创建简单的文本标签(在实际项目中可以使用更复杂的文本渲染)
// 创建简单的文本标签
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = 256;
canvas.height = 128;
canvas.width = 512;
canvas.height = 256;
context.fillStyle = `#${color.toString(16).padStart(6, '0')}`;
context.font = '24px Arial';
context.fillStyle = `#${new THREE.Color(color).getHexString()}`;
context.font = '24px Microsoft YaHei';
context.textAlign = 'center';
context.fillText(text, 128, 64);
context.textBaseline = 'middle';
// 支持多行文本
const lines = text.split('\n');
lines.forEach((line, i) => {
context.fillText(line, 256, 128 + (i - (lines.length-1)/2) * 30);
});
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture });
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(material);
sprite.position.set(position[0], 80, position[2]);
sprite.scale.set(100, 50, 1);
sprite.position.set(position[0], position[1], position[2]);
sprite.scale.set(200, 100, 1);
scene.add(sprite);
return sprite;
};
// 鼠标点击事件处理
const onMouseClick = async (event) => {
// 计算鼠标位置标准化设备坐标 (-1 到 +1)
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// 通过摄像机和鼠标位置更新射线
raycaster.setFromCamera(mouse, camera);
// 计算物体和射线的交点
const intersects = raycaster.intersectObjects(scene.children);
for (let i = 0; i < intersects.length; i++) {
if (intersects[i].object.userData.location) {
const region = intersects[i].object.userData.location;
selectedRegionData.value = await fetchRegionDetail(region.id);
showRegionDetail.value = true;
highlightRegion(intersects[i].object);
break;
}
}
};
// 鼠标移动事件处理(悬停效果)
const onMouseMove = (event) => {
// 计算鼠标位置标准化设备坐标 (-1 到 +1)
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// 通过摄像机和鼠标位置更新射线
raycaster.setFromCamera(mouse, camera);
// 计算物体和射线的交点
const intersects = raycaster.intersectObjects(scene.children);
// 重置所有地标的颜色
resetRegionColors();
// 高亮显示悬停的地標
for (let i = 0; i < intersects.length; i++) {
if (intersects[i].object.userData.location) {
intersects[i].object.material.emissive = new THREE.Color(0x222222);
break;
}
}
};
// 高亮选中区域
const highlightRegion = (mesh) => {
resetRegionColors();
mesh.material.emissive = new THREE.Color(0x333333);
};
// 重置区域颜色
const resetRegionColors = () => {
scene.children.forEach(child => {
if (child.isMesh && child.userData.location) {
child.material.emissive = new THREE.Color(0x000000);
}
});
};
// 关闭区域详情
const closeRegionDetail = () => {
showRegionDetail.value = false;
selectedRegionData.value = {};
resetRegionColors();
};
// 动画循环
@@ -187,6 +310,13 @@ export default {
}
};
// 监听地图数据变化
watch(() => props.mapData, () => {
if (scene) {
createLandmarks();
}
}, { deep: true });
// 清理资源
const cleanup = () => {
if (animationId) {
@@ -194,6 +324,8 @@ export default {
}
if (renderer) {
renderer.domElement.removeEventListener('click', onMouseClick, false);
renderer.domElement.removeEventListener('mousemove', onMouseMove, false);
mapContainer.value.removeChild(renderer.domElement);
renderer.dispose();
}
@@ -221,7 +353,10 @@ export default {
});
return {
mapContainer
mapContainer,
showRegionDetail,
selectedRegionData,
closeRegionDetail
};
}
};
@@ -231,6 +366,7 @@ export default {
.three-d-map-container {
width: 100%;
height: 100%;
position: relative;
}
.map-canvas {

View File

@@ -1,6 +1,6 @@
import axios from 'axios';
const API_BASE_URL = 'http://localhost:3000/api/v1/dashboard';
const API_BASE_URL = 'http://localhost:8000/api/v1/dashboard';
export const fetchOverviewData = async () => {
try {
@@ -50,4 +50,24 @@ export const fetchFinanceData = async (type) => {
console.error('Error fetching finance data:', error);
return [];
}
};
export const fetchMapData = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/map/regions`);
return response.data;
} catch (error) {
console.error('Error fetching map data:', error);
return [];
}
};
export const fetchRegionDetail = async (regionId) => {
try {
const response = await axios.get(`${API_BASE_URL}/map/region/${regionId}`);
return response.data;
} catch (error) {
console.error('Error fetching region detail:', error);
return {};
}
};

View File

@@ -53,25 +53,16 @@
<!-- 中间区域 -->
<div class="center-section">
<!-- 产业概览 -->
<!-- 产业概览地图 -->
<div class="center-top">
<div class="center-border card">
<h2>产业概览</h2>
<div class="center-content">
<div class="total-count">
<div class="count-title">牛只总数</div>
<div class="count-value">128,456</div>
<div class="count-unit"></div>
</div>
<div class="total-count">
<div class="count-title">牧场数量</div>
<div class="count-value">1,245</div>
<div class="count-unit"></div>
</div>
<div class="growth-rate">
<div class="rate-title">同比增长</div>
<div class="rate-value positive">5.2%</div>
</div>
<h2>锡林郭勒盟产业分布</h2>
<div class="map-container">
<ThreeDMap
:height="300"
:map-data="mapData"
ref="threeDMap"
/>
</div>
</div>
</div>
@@ -126,15 +117,21 @@
<script>
import * as echarts from 'echarts'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import ThreeDMap from '@/components/map/ThreeDMap.vue'
import { fetchMapData } from '@/services/dashboard.js'
export default {
name: 'Dashboard',
components: {
ThreeDMap
},
setup() {
const currentTime = ref(new Date().toLocaleString())
const breedingChart = ref(null)
const transactionChart = ref(null)
const regionChart = ref(null)
const riskRadarChart = ref(null)
const threeDMap = ref(null)
let breedingChartInstance = null
let transactionChartInstance = null
@@ -158,6 +155,9 @@ export default {
{ time: '08-18 16:15', type: '运输风险', desc: '运输路线受阻', status: '已处理' }
])
// 地图数据
const mapData = ref([])
// 初始化图表
const initCharts = () => {
// 确保 DOM 元素已正确绑定
@@ -255,6 +255,35 @@ export default {
}
}
// 获取地图数据
const loadMapData = async () => {
try {
const data = await fetchMapData()
if (data.regions && data.regions.length > 0) {
mapData.value = data.regions
} else {
// 使用默认数据
mapData.value = [
{ id: 'xlg', name: '锡林浩特市', cattle_count: 25600, farm_count: 120, coordinates: [116.093, 43.946] },
{ id: 'dwq', name: '东乌旗', cattle_count: 18500, farm_count: 95, coordinates: [116.980, 45.514] },
{ id: 'xwq', name: '西乌旗', cattle_count: 21200, farm_count: 108, coordinates: [117.615, 44.587] },
{ id: 'abg', name: '阿巴嘎旗', cattle_count: 16800, farm_count: 86, coordinates: [114.971, 44.022] },
{ id: 'snz', name: '苏尼特左旗', cattle_count: 12400, farm_count: 65, coordinates: [113.653, 43.859] }
]
}
} catch (error) {
console.error('获取地图数据失败:', error)
// 使用默认数据
mapData.value = [
{ id: 'xlg', name: '锡林浩特市', cattle_count: 25600, farm_count: 120, coordinates: [116.093, 43.946] },
{ id: 'dwq', name: '东乌旗', cattle_count: 18500, farm_count: 95, coordinates: [116.980, 45.514] },
{ id: 'xwq', name: '西乌旗', cattle_count: 21200, farm_count: 108, coordinates: [117.615, 44.587] },
{ id: 'abg', name: '阿巴嘎旗', cattle_count: 16800, farm_count: 86, coordinates: [114.971, 44.022] },
{ id: 'snz', name: '苏尼特左旗', cattle_count: 12400, farm_count: 65, coordinates: [113.653, 43.859] }
]
}
}
// 更新时间
const updateTime = () => {
currentTime.value = new Date().toLocaleString()
@@ -270,6 +299,7 @@ export default {
onMounted(() => {
initCharts()
loadMapData()
timer = setInterval(updateTime, 1000)
window.addEventListener('resize', resizeCharts)
})
@@ -287,10 +317,12 @@ export default {
currentTime,
keyMetrics,
riskData,
mapData,
breedingChart,
transactionChart,
regionChart,
riskRadarChart
riskRadarChart,
threeDMap
}
}
}
@@ -425,15 +457,15 @@ export default {
}
.center-top {
height: 20%;
height: 40%;
}
.center-middle {
height: 50%;
height: 35%;
}
.center-bottom {
height: 30%;
height: 25%;
}
.center-border, .center-chart-border {
@@ -442,6 +474,10 @@ export default {
padding: 20px;
}
.map-container {
height: calc(100% - 40px);
}
.center-content {
height: 100%;
display: flex;