Files
AssetsMap/assetsMap/map.html
2026-06-18 16:26:31 +08:00

1002 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>资产地图</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:"Microsoft YaHei",Arial,sans-serif; height:100vh; display:flex; overflow:hidden; }
#app { display:flex; width:100%; height:100vh; }
#mapWrap { flex:1; position:relative; min-width:0; height:100%; }
#map { height:100%; width:100%; }
#sidePanel {
width:380px; height:100vh; background:#fff;
border-left:1px solid #e0e0e0;
display:flex; flex-direction:column; flex-shrink:0;
position:relative; z-index:10000;
box-shadow:-2px 0 12px rgba(0,0,0,0.08);
}
.filter-section {
padding:12px 16px; border-bottom:2px solid #eee; background:#fff; flex-shrink:0;
}
.filter-section .title {
font-size:14px; font-weight:bold; color:#333; margin-bottom:10px;
display:flex; justify-content:space-between; align-items:center;
}
.filter-section .title button {
background:none; border:none; color:#e74c3c; font-size:18px; cursor:pointer;
}
.filter-group { margin-bottom:8px; }
.filter-group label { display:block; font-size:12px; color:#888; margin-bottom:3px; }
.filter-group select, .filter-group input[type="text"] {
width:100%; padding:8px 10px; border:1px solid #ddd; border-radius:6px;
font-size:13px; background:#fff;
}
.filter-group select:focus, .filter-group input:focus {
outline:none; border-color:#e74c3c; box-shadow:0 0 0 2px rgba(231,76,60,0.1);
}
.filter-actions { display:flex; gap:8px; margin-top:10px; }
.btn {
flex:1; padding:9px 0; border:none; border-radius:6px;
cursor:pointer; font-size:13px; font-weight:bold; transition:all 0.2s;
}
.btn-primary { background:#e74c3c; color:#fff; }
.btn-primary:hover { background:#c0392b; }
.btn-reset { background:#f5f5f5; color:#666; border:1px solid #e0e0e0; }
.asset-section {
flex:1; overflow-y:auto; display:flex; flex-direction:column;
}
.asset-section .section-header {
padding:10px 16px; font-size:13px; font-weight:bold; color:#333;
background:#f7f8fa; border-bottom:1px solid #eee; flex-shrink:0;
}
.asset-section .section-header b { color:#e74c3c; margin:0 2px; }
.asset-list { flex:1; overflow-y:auto; }
.asset-item {
padding:12px 16px; border-bottom:1px solid #f0f0f0; cursor:pointer;
border-left:3px solid transparent;
}
.asset-item:hover { background:#f8f9fa; }
.asset-item.active { background:#fdedec; border-left-color:#e74c3c; }
.asset-item .name { font-size:14px; font-weight:bold; color:#333; }
.asset-item .info { font-size:12px; color:#888; margin-top:3px; line-height:1.6; }
.asset-item .actions { display:flex; gap:6px; margin-top:8px; }
.asset-item .actions button {
padding:5px 10px; border:1px solid #ddd; border-radius:4px;
background:#fff; cursor:pointer; font-size:12px;
}
.asset-item .actions button:hover { border-color:#e74c3c; color:#e74c3c; }
.asset-item .actions .btn-detail { background:#e74c3c; color:#fff; border-color:#e74c3c; }
.empty-tip { padding:30px 16px; text-align:center; color:#ccc; font-size:14px; }
.loading-tip { padding:15px 16px; text-align:center; color:#999; font-size:13px; }
.custom-marker-icon { background:none !important; border:none !important; }
/* 筛选区折叠 */
.filter-section > div:nth-child(2) { transition:all 0.3s ease; overflow:hidden; }
/* 多级联动选择器 */
.cascade-wrap {
border:1px solid #ddd; border-radius:6px; background:#fff;
margin-top:4px; overflow:hidden;
}
.cascade-columns {
display:flex; overflow-x:auto; min-height:120px; max-height:240px;
}
.cascade-col {
min-width:140px; max-width:180px; flex-shrink:0;
border-right:1px solid #eee; overflow-y:auto;
}
.cascade-col:last-child { border-right:none; }
.cascade-col .col-item {
padding:10px 12px; font-size:13px; color:#333; cursor:pointer;
border-bottom:1px solid #f5f5f5; display:flex; align-items:center; justify-content:space-between;
}
.cascade-col .col-item:hover { background:#f0f0f0; }
.cascade-col .col-item.active { background:#fdedec; color:#e74c3c; font-weight:bold; }
.cascade-col .col-item .arrow { color:#bbb; font-size:11px; margin-left:4px; }
.cascade-col .col-item .check { color:#e74c3c; font-size:14px; }
.cascade-footer {
display:flex; justify-content:space-between; align-items:center;
padding:6px 12px; border-top:1px solid #eee; background:#f9f9f9;
}
.cascade-footer .selected-text { font-size:12px; color:#e74c3c; }
.cascade-footer .clear-btn {
font-size:12px; color:#999; cursor:pointer; background:none; border:none;
}
.cascade-footer .clear-btn:hover { color:#e74c3c; }
/* 标点重叠切换弹框 */
.switch-popup {
background:#fff; border-radius:8px; padding:8px; min-width:200px; max-width:280px;
box-shadow:0 4px 16px rgba(0,0,0,0.2); font-size:13px;
}
.switch-popup .sp-title { font-weight:bold; color:#333; margin-bottom:8px; font-size:13px; padding:0 2px; }
.switch-popup .sp-item {
padding:8px 10px; border-radius:6px; cursor:pointer; margin-bottom:4px;
border:1px solid #eee; transition:all 0.15s;
}
.switch-popup .sp-item:hover { background:#f8f9fa; border-color:#e74c3c; }
.switch-popup .sp-item.active { background:#fdedec; border-color:#e74c3c; }
.switch-popup .sp-item:last-child { margin-bottom:0; }
@media (max-width:768px) {
#app { display:block; width:100%; height:100%; position:relative; }
#mapWrap { position:absolute; top:0; left:0; width:100%; height:45%; }
#map { height:100%; width:100%; }
#sidePanel {
position:absolute; bottom:0; left:0;
width:100%; height:55%;
border-left:none; border-top:1px solid #e0e0e0;
border-radius:12px 12px 0 0;
box-shadow:0 -2px 12px rgba(0,0,0,0.08);
}
.filter-group select, .filter-group input[type="text"] { min-height:44px; font-size:15px; }
.btn { min-height:44px; font-size:15px; }
.asset-item { padding:14px 16px; min-height:80px; }
.asset-item .name { font-size:15px; }
.asset-item .info { font-size:13px; }
.asset-item .actions button { padding:8px 12px; font-size:13px; min-height:32px; }
.cascade-col { min-width:120px; }
.cascade-col .col-item { padding:12px 10px; font-size:14px; }
}
</style>
</head>
<body>
<div id="app">
<div id="mapWrap"><div id="map"></div></div>
<div id="sidePanel">
<!-- 筛选区 -->
<div class="filter-section">
<div class="title">
<span>筛选条件</span>
<button @click="filterCollapsed = !filterCollapsed">{{ filterCollapsed ? '▼' : '▲' }}</button>
</div>
<div v-show="!filterCollapsed">
<div class="filter-group">
<label>所属单位</label>
<div v-if="showCascade" class="cascade-wrap">
<div class="cascade-columns">
<div class="cascade-col">
<div v-for="node in deptTree" :key="node.id"
class="col-item" :class="{active: cascadeActiveId === node.id}"
@click="cascadeClick(node, 0)">
<span>{{ node.name }}</span>
<span v-if="node.children && node.children.length" class="arrow"></span>
<span v-else-if="filterOwner === node.name" class="check"></span>
</div>
</div>
<template v-for="(children, idx) in cascadeChildren">
<div class="cascade-col" :key="'col-'+idx" v-if="children && children.length > 0">
<div v-for="node in children" :key="node.id"
class="col-item" :class="{active: cascadeActiveId === node.id}"
@click="cascadeClick(node, idx + 1)">
<span>{{ node.name }}</span>
<span v-if="node.children && node.children.length" class="arrow"></span>
<span v-else-if="filterOwner === node.name" class="check"></span>
</div>
</div>
</template>
</div>
<div class="cascade-footer">
<span class="selected-text" v-if="filterOwner">已选:{{ filterOwner }}</span>
<span class="selected-text" v-else>请选择单位</span>
<div>
<button v-if="filterOwner" class="clear-btn" @click="clearCascadeSelect">清除</button>
<button class="clear-btn" style="color:#888;margin-left:8px;" @click="showCascade = false">收起</button>
</div>
</div>
</div>
<div v-else @click="showCascade = true" style="padding:8px 10px;border:1px solid #ddd;border-radius:6px;cursor:pointer;font-size:13px;background:#fff;min-height:20px;">
{{ filterOwner || '点击选择单位...' }}
</div>
</div>
<div class="filter-group">
<label>资产分类</label>
<select v-model="filterCatagory" @change="queryAssets">
<option value="">全部</option>
<option v-for="(val, key) in categoryOptions" :key="key" :value="val.id">{{val.showValue}}</option>
</select>
</div>
<div class="filter-group">
<label>关键字</label>
<input type="text" v-model="keyword" placeholder="资产名称/编号" @keyup.enter="queryAssets">
</div>
<div class="filter-actions">
<button class="btn btn-primary" @click="queryAssets">查询</button>
<button class="btn btn-reset" @click="resetFilter">重置</button>
</div>
</div>
</div>
<!-- 资产列表 -->
<div class="asset-section">
<div class="section-header">资产总数:<b>{{total}}</b></div>
<div class="asset-list" ref="assetListContainer">
<div v-if="assetList.length === 0 && queried" class="empty-tip">未查询到资产数据</div>
<div v-if="assetList.length === 0 && !queried" class="empty-tip">加载中...</div>
<div v-for="(a,index) in assetList" :key="a.id"
class="asset-item" :class="{active: activeAssetId === a.id}"
:ref="'asset-' + a.id"
@click="locateFromList(a)">
<div class="name">{{a.assetName || '-'}}</div>
<div class="info">
编码:{{a.assetCode || a.id}}<br>
位置:{{a.address || '-'}}<br>
面积:{{a.area || '-'}}㎡ 管理单位:{{a.manager || '-'}}
</div>
<div class="actions">
<button class="btn-detail" @click.stop="goToDetail(a)">查看详情</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
/**
* 资产地图主应用
* 功能:展示资产标点、按经纬度分组、加载/高亮边界、多资产切换、筛选查询
*/
new Vue({
el: "#app",
data: {
// ========== 基础配置 ==========
host: 'http://localhost:3080', // 后端服务地址
// ========== 地图实例与图层 ==========
map: null, // Leaflet 地图实例
markerGroup: null, // 标点图层组(承载所有资产标点)
markerIcon: null, // 自定义标点图标
// ========== 筛选条件 ==========
filterOwner: '', // 所属单位筛选值
filterCatagory: '', // 资产分类筛选值
keyword: '', // 关键字搜索
deptTree: [], // 部门树形数据(用于级联选择器)
deptOptions: {}, // 部门扁平化数据 {name: id}
categoryOptions: {}, // 资产分类选项
showCascade: false, // 是否展开级联选择器
cascadeActiveId: null, // 级联选择器当前选中节点ID
cascadeChildren: [], // 级联选择器子级列数据
filterCollapsed: false, // 筛选区是否折叠
// ========== 资产数据 ==========
assetList: [], // 当前查询到的资产列表
total: 0, // 资产总数
queried: false, // 是否已执行过查询(控制空状态提示)
// ========== 标点与边界管理 ==========
isMobile: false, // 是否移动端
markerMap: {}, // 标点映射lat_lng_key -> L.marker用于列表点击定位标点
markerBoundLayers: {}, // 边界图层映射markerKey -> L.layerGroup每个标点独立管理边界
// ========== 选中状态 ==========
activeAssetId: null, // 当前选中的资产ID用于列表高亮
activeMarker: null, // 当前高亮的标点对象
_syncFromMap: false // 防止地图点击与列表选中双向触发的标志
},
/**
* 生命周期:初始化
* 1. 检测设备类型
* 2. 初始化地图
* 3. 解析URL参数决定是定位单个资产还是加载筛选列表
*/
mounted() {
this.checkDevice();
window.addEventListener('resize', () => this.checkDevice());
this.initMap();
// URL参数 assetId 支持从其他页面穿透定位到指定资产
var params = new URLSearchParams(window.location.search);
var assetId = params.get('assetId');
if (assetId) {
this.loadAssetById(assetId);
} else {
// 无指定资产时,加载筛选选项后默认查询
this.loadFilterOptions().then(() => {
this.queryAssets();
});
}
},
methods: {
// =====================================================================
// 设备检测
// =====================================================================
/**
* 检测是否为移动端设备
* 通过UA关键字或屏幕宽度 <= 768px 判断
*/
checkDevice() {
this.isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) || window.innerWidth <= 768;
},
// =====================================================================
// 地图初始化
// =====================================================================
/**
* 初始化Leaflet地图
* - 设置默认视口(恩施地区)
* - 加载离线瓦片图层
* - 创建自定义标点图标48x48 PNG
* - 创建标点图层组
* - 绑定地图空白处点击事件(清除选中状态)
*/
initMap() {
this.map = L.map('map').setView([30.2856, 109.4783], 13);
L.tileLayer('./map/{z}/{x}/{y}/tile.png', { maxZoom: 18, minZoom: 8 }).addTo(this.map);
// 延迟刷新地图尺寸,确保容器已渲染完成
setTimeout(() => this.map.invalidateSize(), 100);
// 自定义标点图标
this.markerIcon = L.divIcon({
html: '<img src="./markicon.png" style="width:48px;height:48px;">',
className: 'custom-marker-icon',
iconSize: [48, 48],
iconAnchor: [24, 24]
});
// 标点图层组
this.markerGroup = L.layerGroup().addTo(this.map);
// 地图空白处点击:清除标点高亮和选中状态
this.map.on('click', () => {
this.clearActiveMarker();
this._syncFromMap = true;
this.activeAssetId = null;
this._syncFromMap = false;
});
},
/**
* 懒初始化SVG斜线填充图案
* SVG renderer 在首次添加矢量图层后才创建,所以延迟到首次高亮时初始化
* 定义 <pattern id="hatch"> 元素45度斜线灰色
*/
_ensureHatchPattern() {
if (this._hatchReady) return;
var svg = document.querySelector('.leaflet-overlay-pane svg');
if (!svg) return;
var defs = svg.querySelector('defs');
if (!defs) {
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
svg.insertBefore(defs, svg.firstChild);
}
if (svg.querySelector('#hatch')) { this._hatchReady = true; return; }
var pattern = document.createElementNS('http://www.w3.org/2000/svg', 'pattern');
pattern.setAttribute('id', 'hatch');
pattern.setAttribute('patternUnits', 'userSpaceOnUse');
pattern.setAttribute('width', '6');
pattern.setAttribute('height', '6');
pattern.setAttribute('patternTransform', 'rotate(45)');
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', '0');
line.setAttribute('y1', '0');
line.setAttribute('x2', '0');
line.setAttribute('y2', '6');
line.setAttribute('stroke', '#555');
line.setAttribute('stroke-width', '1.5');
pattern.appendChild(line);
defs.appendChild(pattern);
this._hatchReady = true;
},
// =====================================================================
// 筛选选项加载
// =====================================================================
/**
* 并行加载筛选选项(所属单位 + 资产分类)
* @returns {Promise} 等待两个接口都完成
*/
loadFilterOptions() {
var self = this;
return Promise.all([
// 加载部门树
fetch(this.host + "/seeyon/rest/assetsRegion/getDeptOptions")
.then(r => r.json()).then(d => {
if (d.code === 0) {
self.deptTree = d.data || [];
self.deptOptions = self.flattenDeptTree(self.deptTree);
}
}),
// 加载资产分类
fetch(this.host + "/seeyon/rest/assetsRegion/getCategoryOptions")
.then(r => r.json()).then(d => { if (d.code === 0) self.categoryOptions = d.data || {}; })
]);
},
/**
* 将树形部门数据扁平化为 {name: id} 映射
* 用于筛选下拉框的选项值
* @param {Array} tree - 树形数据
* @returns {Object} 扁平化映射
*/
flattenDeptTree(tree) {
var result = {};
function walk(nodes) {
if (!nodes) return;
nodes.forEach(function(node) {
result[node.name] = node.id;
if (node.children && node.children.length > 0) walk(node.children);
});
}
walk(tree);
return result;
},
// =====================================================================
// 级联选择器交互
// =====================================================================
/**
* 级联选择器节点点击
* @param {Object} node - 当前点击的节点
* @param {number} level - 当前层级0=根)
*/
cascadeClick(node, level) {
this.cascadeActiveId = node.id;
if (node.children && node.children.length > 0) {
// 有子级:展开下一级列
var newChildren = this.cascadeChildren.slice(0, level);
newChildren[level] = node.children;
this.cascadeChildren = newChildren;
} else {
// 无子级:选中该单位并关闭选择器
this.filterOwner = node.name;
this.showCascade = false;
}
},
/** 清除级联选择器选中状态 */
clearCascadeSelect() {
this.filterOwner = '';
this.cascadeActiveId = null;
this.cascadeChildren = [];
},
// =====================================================================
// 筛选与查询
// =====================================================================
/** 重置所有筛选条件并清空地图标点和边界 */
resetFilter() {
this.filterOwner = ''; this.filterCatagory = ''; this.keyword = '';
this.assetList = []; this.queried = false; this.total = 0;
this.markerGroup.clearLayers();
this.clearAllBoundLayers();
this.markerMap = {};
this.clearActiveMarker();
this.activeAssetId = null;
},
/**
* 清除所有marker的边界图层
* 遍历 markerBoundLayers 中每个 layerGroup 并从地图移除
*/
clearAllBoundLayers() {
var self = this;
Object.keys(this.markerBoundLayers).forEach(function(key) {
self.map.removeLayer(self.markerBoundLayers[key]);
});
this.markerBoundLayers = {};
},
/**
* 查询资产列表
* - 清空现有标点和边界
* - 调用 queryAssetList 接口获取分页数据
* - 成功后构建标点
*/
queryAssets() {
var self = this;
this.queried = true;
// 清空现有图层
this.markerGroup.clearLayers();
this.clearAllBoundLayers();
this.markerMap = {};
this.clearActiveMarker();
this.activeAssetId = null;
// 构建查询参数
var body = {
owner: this.filterOwner,
level3: this.filterCatagory,
keyword: this.keyword,
pageNum: 1,
pageSize: 20
};
fetch(this.host + "/seeyon/rest/assetsRegion/queryAssetList", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(r => r.json()).then(d => {
if (d.code === 0) {
self.assetList = d.data.records || [];
self.total = d.data.total || 0;
self.buildMarkers();
}
});
},
// =====================================================================
// 标点构建(按经纬度分组)
// =====================================================================
/**
* 按经纬度分组构建标点
* 核心逻辑:
* 1. 将资产按 lat,lng 分组toFixed(8) 避免浮点精度问题)
* 2. 每组创建一个标点,标点上挂载 _assets 数组
* 3. 绑定点击事件:选中 + 弹框 + 高亮边界
* 4. 自动加载该标点所有资产的边界
*/
buildMarkers() {
this.markerGroup.clearLayers();
this.markerMap = {};
this.markerBoundLayers = {};
var self = this;
// Step 1: 按经纬度分组
var groups = {};
this.assetList.forEach(function(a) {
var lat = parseFloat(a.lat);
var lng = parseFloat(a.lng);
if (isNaN(lat) || isNaN(lng)) return;
var key = lat.toFixed(8) + ',' + lng.toFixed(8);
if (!groups[key]) groups[key] = { lat: lat, lng: lng, assets: [] };
groups[key].assets.push(a);
});
// Step 2: 为每组创建标点
Object.keys(groups).forEach(function(key) {
var group = groups[key];
var m = L.marker([group.lat, group.lng], { icon: self.markerIcon });
m._groupKey = key; // 分组key用于关联边界图层
m._assets = group.assets; // 该位置的所有资产
m._activeIdx = 0; // 当前展示的资产索引
// Step 3: 标点点击事件
m.on('click', function() {
self.highlightMarker(m);
var activeIdx = m._activeIdx || 0;
// 注释:切换单资产边界逻辑已屏蔽,改为一次性加载所有资产边界
// self.loadMarkerBound(m, activeIdx);
self.activeAssetId = m._assets[activeIdx].id;
self.highlightBoundForAsset(m._assets[activeIdx].id);
self.showAssetPopup(m, m._assets[activeIdx]);
self.scrollListToAsset(m._assets[activeIdx].id);
// 多资产时额外弹出切换弹框
if (m._assets.length > 1) {
self.showSwitchPopup(m);
}
});
m.addTo(self.markerGroup);
self.markerMap[key] = m;
// Step 4: 自动加载该标点所有资产的边界
self.loadAllMarkerBounds(m);
});
},
// =====================================================================
// 边界加载与高亮
// =====================================================================
/**
* 为指定资产的边界添加斜线填充效果
* 选中区域内部填充斜线纹理SVG pattern边框加粗
* 非选中:恢复默认纯色填充
* @param {string} assetId - 要高亮的资产ID
*/
highlightBoundForAsset(assetId) {
this._ensureHatchPattern();
var self = this;
Object.keys(this.markerBoundLayers).forEach(function(key) {
var layerGroup = self.markerBoundLayers[key];
layerGroup.eachLayer(function(layer) {
if (!layer.setStyle) return;
if (layer._assetId === assetId) {
// 选中:斜线填充 + 加粗边框
layer.setStyle({ weight: 3, fillOpacity: 1 });
layer.bringToFront();
// 用SVG pattern替换填充为斜线纹理
if (layer._path) {
layer._path.setAttribute('fill', 'url(#hatch)');
}
} else {
// 非选中:恢复默认纯色填充
layer.setStyle({ weight: 2, fillOpacity: 0.3 });
if (layer._path) {
layer._path.setAttribute('fill', layer.options.fillColor || layer.options.color);
}
}
});
});
},
/**
* 加载标点内所有资产的边界(一次性全部加载)
* 每个资产的边界独立请求,渲染到同一个 layerGroup
* polygon 通过 _assetId 属性关联到对应资产,用于后续高亮
* @param {L.Marker} m - 标点对象,包含 _assets 数组和 _groupKey
*/
loadAllMarkerBounds(m) {
var self = this;
// 清除该marker旧的边界图层
if (self.markerBoundLayers[m._groupKey]) {
self.map.removeLayer(self.markerBoundLayers[m._groupKey]);
}
var layerGroup = L.layerGroup().addTo(self.map);
self.markerBoundLayers[m._groupKey] = layerGroup;
// 遍历每个资产,异步加载边界
m._assets.forEach(function(asset) {
var assetId = asset.id || asset.assetCode;
if (!assetId) return;
var url = self.host + "/seeyon/rest/assetsRegion/getAssetBound?assetId=" + encodeURIComponent(assetId);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.code === 0 && d.data && d.data.boundData) {
var bound = typeof d.data.boundData === 'string' ? JSON.parse(d.data.boundData) : d.data.boundData;
var regions = self.normalizeBoundary(bound);
var color = bound.color || '#e74c3c';
regions.forEach(function(region) {
if (!region.outer || region.outer.length < 3) return;
// rings: [外圈坐标, ...孔洞坐标]
var rings = [region.outer].concat(region.holes || []);
var polygon = L.polygon(rings, {
color: color, fillColor: color, weight: 2, fillOpacity: 0.3
});
polygon._assetId = asset.id; // 标记归属资产,用于高亮匹配
layerGroup.addLayer(polygon);
});
}
})
.catch(function(err) { console.error('[map] 加载边界失败:', err); });
});
},
/**
* 加载指定marker的单个资产的边界暂保留供后续切换边界功能使用
* 当前已屏蔽调用,改为 loadAllMarkerBounds 一次性加载
* @param {L.Marker} m - 标点对象
* @param {number} assetIdx - 资产在 _assets 数组中的索引
*/
loadMarkerBound(m, assetIdx) {
var self = this;
var asset = m._assets[assetIdx];
if (!asset) { console.warn('[map] asset为空, idx:', assetIdx); return; }
var assetId = asset.id || asset.assetCode;
if (!assetId) { console.warn('[map] assetId为空:', asset); return; }
// 清除该marker旧的边界图层
if (self.markerBoundLayers[m._groupKey]) {
self.map.removeLayer(self.markerBoundLayers[m._groupKey]);
}
var layerGroup = L.layerGroup().addTo(self.map);
self.markerBoundLayers[m._groupKey] = layerGroup;
m._activeIdx = assetIdx;
self.activeAssetId = asset.id;
var url = self.host + "/seeyon/rest/assetsRegion/getAssetBound?assetId=" + encodeURIComponent(assetId);
console.log('[map] loadMarkerBound, assetId:', assetId, 'assetName:', asset.assetName);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(d) {
console.log('[map] getAssetBound返回:', d);
if (d.code === 0 && d.data && d.data.boundData) {
var bound = typeof d.data.boundData === 'string' ? JSON.parse(d.data.boundData) : d.data.boundData;
console.log('[map] 解析后的边界:', bound);
var regions = self.normalizeBoundary(bound);
console.log('[map] regions:', regions.length, regions);
var color = bound.color || '#e74c3c';
regions.forEach(function(region) {
if (!region.outer || region.outer.length < 3) {
console.warn('[map] region.outer不足3个点:', region.outer);
return;
}
var rings = [region.outer].concat(region.holes || []);
var polygon = L.polygon(rings, {
color: color, fillColor: color, weight: 2, fillOpacity: 0.3
});
polygon._assetId = asset.id;
layerGroup.addLayer(polygon);
console.log('[map] polygon已添加, 点数:', region.outer.length);
});
} else {
console.warn('[map] 无boundData, code:', d.code, 'data:', d.data);
}
})
.catch(function(err) { console.error('[map] 加载边界失败:', err); });
},
// =====================================================================
// 多资产切换弹框
// =====================================================================
/**
* 显示资产切换弹框(多资产标点点击时触发)
* 弹框内列出该位置所有资产,点击可切换信息弹框和列表高亮
* 注:边界已一次性加载,切换时只更新信息和高亮,不重新加载边界
* @param {L.Marker} m - 标点对象
*/
showSwitchPopup(m) {
var self = this;
var activeIdx = m._activeIdx || 0;
// 构建弹框HTML
var html = '<div class="switch-popup">';
html += '<div class="sp-title">该位置有 ' + m._assets.length + ' 个资产</div>';
m._assets.forEach(function(a, idx) {
var isActive = idx === activeIdx;
html += '<div class="sp-item' + (isActive ? ' active' : '') + '" data-idx="' + idx + '">';
html += '<div style="font-weight:bold;">' + (a.assetName || '-') + '</div>';
html += '<div style="font-size:11px;color:#999;margin-top:2px;">编码:' + (a.assetCode || a.id) + '</div>';
html += '</div>';
});
html += '</div>';
// 打开弹框Leaflet popup
L.popup({ closeButton: true, className: 'switch-popup-container', offset: [0, -24] })
.setLatLng(m.getLatLng())
.setContent(html)
.openOn(self.map);
// 延迟绑定点击事件等待DOM渲染完成
setTimeout(function() {
var items = document.querySelectorAll('.switch-popup .sp-item');
items.forEach(function(item) {
item.addEventListener('click', function() {
var idx = parseInt(this.getAttribute('data-idx'));
// 注释:切换边界逻辑已屏蔽,改为一次性加载所有资产边界
// if (idx !== activeIdx) {
// self.loadMarkerBound(m, idx);
// }
m._activeIdx = idx;
self.activeAssetId = m._assets[idx].id;
self.highlightBoundForAsset(m._assets[idx].id);
self.scrollListToAsset(m._assets[idx].id);
// showAssetPopup 会替换当前弹框,不需要手动 close
self.showAssetPopup(m, m._assets[idx]);
});
});
}, 50);
},
// =====================================================================
// 列表交互
// =====================================================================
/**
* 从右侧列表点击资产时触发
* - 高亮对应标点
* - 高亮对应边界
* - 显示信息弹框
* - 地图定位到该资产位置
* @param {Object} asset - 资产对象
*/
locateFromList(asset) {
var lat = parseFloat(asset.lat);
var lng = parseFloat(asset.lng);
if (isNaN(lat) || isNaN(lng)) return;
this.activeAssetId = asset.id;
var key = lat.toFixed(8) + ',' + lng.toFixed(8);
var m = this.markerMap[key];
if (m) {
this.highlightMarker(m);
// 找到该资产在marker中的索引
var idx = m._assets.findIndex(function(a) { return a.id === asset.id; });
if (idx < 0) idx = 0;
// 注释:切换边界逻辑已屏蔽
// this.loadMarkerBound(m, idx);
m._activeIdx = idx;
this.highlightBoundForAsset(asset.id);
this.showAssetPopup(m, asset);
}
// 仅平移到资产位置,不改变缩放层级
this.map.panTo([lat, lng]);
},
/**
* 滚动右侧列表到指定资产项
* 使用 $nextTick 确保DOM已更新后再滚动
* @param {string} assetId - 资产ID
*/
scrollListToAsset(assetId) {
this.$nextTick(function() {
var ref = this.$refs['asset-' + assetId];
if (ref && ref[0]) {
ref[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
},
// =====================================================================
// 边界数据解析
// =====================================================================
/**
* 标准化边界数据格式
* 兼容两种格式:
* 1. 新格式:{ regions: [{id, outer, holes}], color }
* 2. 旧格式:{ polygon: [...], color }polygon 可能是单层或多层嵌套)
* @param {Object} bound - 原始边界数据
* @returns {Array} 标准化后的 regions 数组 [{id, outer, holes}]
*/
normalizeBoundary(bound) {
if (!bound) return [];
var self = this;
// 新格式:直接取 regions 数组
if (bound.regions && Array.isArray(bound.regions)) {
return bound.regions.map(r => ({ id: r.id, outer: r.outer || [], holes: r.holes || [] }));
}
// 旧格式:从 polygon 字段解析
if (!bound.polygon || bound.polygon.length === 0) return [];
var p = bound.polygon;
// 单个多边形polygon[0] = [lat, lng]
if (typeof p[0][0] === 'number') return [{ id: 0, outer: p, holes: [] }];
// 单个带孔洞polygon[0] = outer, polygon[1..] = holes
if (typeof p[0][0][0] === 'number') return [{ id: 0, outer: p[0], holes: p.slice(1) }];
// 多个多边形
return p.map(function(r, i) {
if (typeof r[0][0] === 'number') return { id: i, outer: r, holes: [] };
return { id: i, outer: r[0], holes: r.slice(1) };
});
},
// =====================================================================
// 弹框与标点高亮
// =====================================================================
/**
* 显示资产基本信息弹框(在标点位置弹出)
* @param {L.Marker} m - 标点对象(用于定位弹框位置)
* @param {Object} asset - 资产对象
*/
showAssetPopup(m, asset) {
var area = asset.area || '-';
var html =
'<b>资产编码:</b>' + (asset.assetCode || asset.id) + '<br>' +
'<b>资产名称:</b>' + (asset.assetName || '-') + '<br>' +
'<b>坐落位置:</b>' + (asset.address || '-') + '<br>' +
'<b>面积:</b>' + area + '㎡<br>' +
'<b>管理单位:</b>' + (asset.manager || '-');
L.popup({ offset: [0, -24] })
.setLatLng(m.getLatLng())
.setContent(html)
.openOn(this.map);
},
/**
* 高亮指定标点(红色发光 + 放大效果)
* 同时取消上一个高亮标点
* @param {L.Marker} m - 要高亮的标点
*/
highlightMarker(m) {
if (this.activeMarker && this.activeMarker !== m) {
this.unhighlightMarker(this.activeMarker);
}
var el = m.getElement();
if (el) {
var img = el.querySelector('img') || el;
img.style.filter = 'drop-shadow(0 0 6px #e74c3c) drop-shadow(0 0 12px #e74c3c) drop-shadow(0 0 20px rgba(231,76,60,0.6))';
img.style.transform = 'scale(2)';
img.style.transformOrigin = 'center center';
el.style.zIndex = 1000;
}
this.activeMarker = m;
},
/**
* 取消标点高亮(恢复原始样式)
* @param {L.Marker} m - 要取消高亮的标点
*/
unhighlightMarker(m) {
var el = m.getElement();
if (el) {
var img = el.querySelector('img') || el;
img.style.filter = '';
img.style.transform = '';
el.style.zIndex = '';
}
},
/** 清除当前活跃标点的高亮状态 */
clearActiveMarker() {
if (this.activeMarker) {
this.unhighlightMarker(this.activeMarker);
this.activeMarker = null;
}
this.clearBoundHighlight();
},
/** 清除所有边界的斜线填充效果,恢复默认纯色填充 */
clearBoundHighlight() {
var self = this;
Object.keys(this.markerBoundLayers).forEach(function(key) {
var layerGroup = self.markerBoundLayers[key];
layerGroup.eachLayer(function(layer) {
if (!layer.setStyle) return;
layer.setStyle({ weight: 2, fillOpacity: 0.3 });
if (layer._path) {
layer._path.setAttribute('fill', layer.options.fillColor || layer.options.color);
}
});
});
},
// =====================================================================
// URL穿透定位
// =====================================================================
/**
* 根据资产编号URL参数定位到指定资产
* 流程:调 getByBounds 接口 → 构建标点 → 地图定位 → 自动选中目标资产
* @param {string} assetId - 资产编号来自URL参数
*/
loadAssetById(assetId) {
var self = this;
fetch(this.host + "/seeyon/rest/assetsRegion/getByBounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: assetId })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.code === 0 && d.data && d.data.length > 0) {
var region = d.data[0];
var lat = parseFloat(region.lat), lng = parseFloat(region.lng);
if (!isNaN(lat) && !isNaN(lng)) {
self.assetList = region.assets || [];
self.total = self.assetList.length;
self.queried = true;
self.buildMarkers();
self.map.setView([lat, lng], 18);
// 自动定位到目标资产
var target = self.assetList.find(function(a) { return a.assetCode === assetId || a.id === assetId; });
if (target) self.locateFromList(target);
}
}
});
},
// =====================================================================
// 跳转详情
// =====================================================================
/**
* 跳转到资产详情页
* 先根据资产类型获取详情页URL模板再替换资产ID打开新窗口
* @param {Object} item - 资产对象
*/
goToDetail(item) {
var host = this.host;
fetch(this.host + "/seeyon/rest/assetsRegion/getDetailBaseUrlByAssetsType?assetsType=" + item.assetType)
.then(r => r.json()).then(d => {
if (d.code === 0 && d.data) {
window.open(host + d.data.replace(/\{assetsId\}/g, item.id));
}
});
}
}
});
</script>
</body>
</html>