Files
AssetsMap/assetsMap/map.html

1002 lines
37 KiB
HTML
Raw Normal View History

2026-05-21 16:40:50 +08:00
<!DOCTYPE html>
<html>
2026-06-18 16:26:31 +08:00
<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);
}
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
.filter-section {
padding:12px 16px; border-bottom:2px solid #eee; background:#fff; flex-shrink:0;
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
.filter-section .title {
font-size:14px; font-weight:bold; color:#333; margin-bottom:10px;
display:flex; justify-content:space-between; align-items:center;
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
.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; }
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
.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; }
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
.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; }
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
.custom-marker-icon { background:none !important; border:none !important; }
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
/* 筛选区折叠 */
.filter-section > div:nth-child(2) { transition:all 0.3s ease; overflow:hidden; }
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
/* 多级联动选择器 */
.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; }
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
/* 标点重叠切换弹框 */
.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);
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
.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>
2026-05-21 16:40:50 +08:00
</div>
2026-06-18 16:26:31 +08:00
<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 || '点击选择单位...' }}
2026-05-21 16:40:50 +08:00
</div>
</div>
2026-06-18 16:26:31 +08:00
<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>
2026-05-21 16:40:50 +08:00
</div>
2026-06-18 16:26:31 +08:00
<div class="filter-group">
<label>关键字</label>
<input type="text" v-model="keyword" placeholder="资产名称/编号" @keyup.enter="queryAssets">
2026-05-21 16:40:50 +08:00
</div>
2026-06-18 16:26:31 +08:00
<div class="filter-actions">
<button class="btn btn-primary" @click="queryAssets">查询</button>
<button class="btn btn-reset" @click="resetFilter">重置</button>
2026-05-21 16:40:50 +08:00
</div>
2026-06-18 16:26:31 +08:00
</div>
</div>
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
<!-- 资产列表 -->
<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>
2026-05-21 16:40:50 +08:00
</div>
</div>
</div>
</div>
2026-06-18 16:26:31 +08:00
</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);
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
}),
// 加载资产分类
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();
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
});
},
// =====================================================================
// 标点构建(按经纬度分组)
// =====================================================================
/**
* 按经纬度分组构建标点
* 核心逻辑:
* 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);
2026-05-21 16:40:50 +08:00
}
}
2026-06-18 16:26:31 +08:00
});
});
},
/**
* 加载标点内所有资产的边界(一次性全部加载)
* 每个资产的边界独立请求,渲染到同一个 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;
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
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);
2026-05-21 16:40:50 +08:00
});
2026-06-18 16:26:31 +08:00
} else {
console.warn('[map] 无boundData, code:', d.code, 'data:', d.data);
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
})
.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);
2026-05-21 16:40:50 +08:00
}
2026-06-18 16:26:31 +08:00
// 仅平移到资产位置,不改变缩放层级
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 = '';
}
},
2026-05-21 16:40:50 +08:00
2026-06-18 16:26:31 +08:00
/** 清除当前活跃标点的高亮状态 */
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>