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>
|