1515 lines
59 KiB
HTML
1515 lines
59 KiB
HTML
<!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 #e74c3c; border-radius:4px;
|
||
background:#e74c3c; color:#fff; cursor:pointer; font-size:12px;
|
||
}
|
||
.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; }
|
||
|
||
/* 移动端触控优化 */
|
||
.media-panel-overlay, .lightbox-overlay, .video-overlay, .pano-overlay { touch-action:none; }
|
||
.media-panel { touch-action:pan-y; }
|
||
.pano-viewer { touch-action:none; }
|
||
|
||
/* 筛选区折叠 */
|
||
.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; }
|
||
|
||
/* 媒体浏览组件 */
|
||
.gallery-link { display:inline-flex; align-items:center; gap:4px; padding:6px 0; font-size:13px; color:#e74c3c; cursor:pointer; user-select:none; }
|
||
.gallery-link:hover { text-decoration:underline; }
|
||
|
||
.thumb-list { display:flex; flex-wrap:wrap; gap:4px; padding:4px 0; margin:4px 0; }
|
||
.thumb-item { width:56px; height:56px; border-radius:4px; overflow:hidden; cursor:pointer; position:relative; border:2px solid transparent; transition:border-color 0.2s; }
|
||
.thumb-item:hover { border-color:#3498db; }
|
||
.thumb-item img { width:100%; height:100%; object-fit:cover; }
|
||
.thumb-badge { position:absolute; bottom:2px; right:2px; background:rgba(0,0,0,0.7); color:#fff; font-size:10px; padding:1px 3px; border-radius:2px; line-height:1; }
|
||
|
||
.lightbox-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.92); z-index:100100; display:flex; align-items:center; justify-content:center; flex-direction:column; }
|
||
.lightbox-close { position:absolute; top:16px; right:16px; background:none; border:none; color:#fff; font-size:28px; cursor:pointer; z-index:100101; width:40px; height:40px; display:flex; align-items:center; justify-content:center; }
|
||
.lightbox-close:hover { color:#e74c3c; }
|
||
.lightbox-img { max-width:90vw; max-height:80vh; object-fit:contain; }
|
||
.lightbox-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(255,255,255,0.15); border:none; color:#fff; font-size:32px; cursor:pointer; width:48px; height:48px; display:flex; align-items:center; justify-content:center; border-radius:50%; transition:background 0.2s; }
|
||
.lightbox-nav:hover { background:rgba(255,255,255,0.35); }
|
||
.lightbox-prev { left:16px; }
|
||
.lightbox-next { right:16px; }
|
||
.lightbox-counter { color:#fff; font-size:14px; margin-top:12px; }
|
||
|
||
.video-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.92); z-index:100100; display:flex; align-items:center; justify-content:center; flex-direction:column; }
|
||
.video-close { position:absolute; top:16px; right:16px; background:none; border:none; color:#fff; font-size:28px; cursor:pointer; z-index:100101; width:40px; height:40px; display:flex; align-items:center; justify-content:center; }
|
||
.video-close:hover { color:#e74c3c; }
|
||
.video-player { max-width:90vw; max-height:80vh; }
|
||
|
||
.pano-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:#000; z-index:100100; overflow:hidden; }
|
||
.pano-close { position:absolute; top:16px; right:16px; background:rgba(0,0,0,0.6); border:2px solid rgba(255,255,255,0.5); color:#fff; font-size:20px; cursor:pointer; z-index:100101; width:44px; height:44px; display:flex; align-items:center; justify-content:center; border-radius:50%; }
|
||
.pano-close:hover { background:rgba(231,76,60,0.8); border-color:#fff; }
|
||
.pano-viewer { width:100%; height:100%; background-size:auto 100%; background-repeat:repeat-x; background-position:center center; cursor:grab; }
|
||
.pano-viewer.dragging { cursor:grabbing; }
|
||
.pano-hint { position:absolute; bottom:20px; left:50%; transform:translateX(-50%); color:rgba(255,255,255,0.5); font-size:13px; pointer-events:none; }
|
||
|
||
|
||
/* 影像资料弹框 */
|
||
.media-panel-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:100000; display:flex; align-items:center; justify-content:center; }
|
||
.media-panel { width:720px; max-width:90vw; max-height:80vh; background:#fff; border-radius:12px; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,0.25); }
|
||
.media-panel-header { display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-bottom:1px solid #eee; flex-shrink:0; }
|
||
.media-panel-title { font-size:16px; font-weight:bold; color:#333; }
|
||
.media-panel-close { background:none; border:none; font-size:22px; color:#888; cursor:pointer; width:36px; height:36px; display:flex; align-items:center; justify-content:center; border-radius:50%; }
|
||
.media-panel-close:hover { background:#f0f0f0; color:#333; }
|
||
|
||
/* Tab页签 */
|
||
.media-tabs { display:flex; border-bottom:2px solid #eee; flex-shrink:0; }
|
||
.media-tab { flex:1; padding:10px 0; text-align:center; font-size:13px; color:#888; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all 0.2s; }
|
||
.media-tab:hover { color:#333; }
|
||
.media-tab.active { color:#e74c3c; border-bottom-color:#e74c3c; font-weight:bold; }
|
||
|
||
/* Tab内容区 */
|
||
.media-panel-body { flex:1; overflow-y:auto; padding:16px; }
|
||
|
||
/* 图片区 */
|
||
.media-img-grid { display:flex; flex-wrap:wrap; gap:8px; }
|
||
.media-img-item { width:calc(25% - 6px); aspect-ratio:4/3; border-radius:6px; overflow:hidden; cursor:pointer; position:relative; border:2px solid transparent; transition:border-color 0.2s; }
|
||
.media-img-item:hover { border-color:#e74c3c; }
|
||
.media-img-item img { width:100%; height:100%; object-fit:cover; }
|
||
.media-img-item .pano-badge { position:absolute; top:4px; left:4px; background:rgba(0,0,0,0.7); color:#fff; font-size:10px; padding:2px 6px; border-radius:3px; }
|
||
|
||
/* 视频区 */
|
||
.media-video-grid { display:flex; flex-wrap:wrap; gap:8px; }
|
||
.media-video-item { width:calc(33.33% - 6px); aspect-ratio:16/9; border-radius:6px; overflow:hidden; cursor:pointer; position:relative; background:#000; border:2px solid transparent; transition:border-color 0.2s; }
|
||
.media-video-item:hover { border-color:#3498db; }
|
||
.media-video-item img { width:100%; height:100%; object-fit:cover; opacity:0.7; }
|
||
.media-video-item .play-icon { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:40px; height:40px; background:rgba(0,0,0,0.6); border-radius:50%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:18px; }
|
||
|
||
/* 附件区 */
|
||
.media-file-list { display:flex; flex-direction:column; gap:4px; }
|
||
.media-file-item { display:flex; align-items:center; gap:10px; padding:10px 12px; border:1px solid #eee; border-radius:6px; cursor:pointer; transition:background 0.15s; }
|
||
.media-file-item:hover { background:#f8f9fa; border-color:#e74c3c; }
|
||
.media-file-icon { width:32px; height:32px; background:#f0f0f0; border-radius:6px; display:flex; align-items:center; justify-content:center; font-size:14px; color:#666; flex-shrink:0; }
|
||
.media-file-info { flex:1; min-width:0; }
|
||
.media-file-name { font-size:13px; color:#333; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.media-file-size { font-size:11px; color:#999; margin-top:2px; }
|
||
.media-empty { text-align:center; padding:40px 0; color:#ccc; font-size:14px; }
|
||
|
||
@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; }
|
||
.cascade-col { min-width:120px; }
|
||
.cascade-col .col-item { padding:12px 10px; font-size:14px; }
|
||
|
||
/* 资产卡片 */
|
||
.asset-item { padding:14px 16px; min-height:80px; }
|
||
.asset-item .name { font-size:15px; }
|
||
.asset-item .info { font-size:13px; }
|
||
.asset-item .actions { flex-wrap:wrap; }
|
||
.asset-item .actions button { padding:8px 14px; font-size:13px; min-height:40px; flex:1; }
|
||
|
||
/* 影像资料弹框:底部弹出式 */
|
||
.media-panel-overlay { align-items:flex-end; }
|
||
.media-panel { width:100%; max-width:100%; max-height:75vh; border-radius:16px 16px 0 0; }
|
||
.media-panel-header { padding:16px 20px; }
|
||
.media-panel-close { width:44px; height:44px; font-size:24px; }
|
||
.media-tab { padding:12px 0; font-size:14px; min-height:44px; }
|
||
.media-panel-body { padding:12px; }
|
||
.media-img-item { width:calc(50% - 4px); }
|
||
.media-video-item { width:100%; }
|
||
.media-file-item { padding:12px; min-height:48px; }
|
||
|
||
/* 灯箱:更大触控区域 */
|
||
.lightbox-close { width:48px; height:48px; font-size:32px; top:12px; right:12px; }
|
||
.lightbox-nav { width:56px; height:56px; font-size:36px; }
|
||
.lightbox-counter { font-size:16px; margin-top:16px; }
|
||
|
||
/* 视频弹框 */
|
||
.video-close { width:48px; height:48px; font-size:32px; }
|
||
.video-player { max-width:100vw; max-height:60vh; }
|
||
|
||
/* 全景弹框 */
|
||
.pano-close { width:48px; height:48px; font-size:24px; }
|
||
.pano-hint { font-size:14px; bottom:24px; }
|
||
}
|
||
</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" :disabled="isAssetView" :style="isAssetView ? 'opacity:0.5;cursor:not-allowed;' : ''">查询</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 v-if="getAssetMedia(a).images.length" class="gallery-link" @click.stop="openLightbox(a, 0)">
|
||
相册({{getAssetMedia(a).images.length}}张)›
|
||
</div>
|
||
<div v-if="getAssetMedia(a).all.length" class="thumb-list">
|
||
<div v-for="(m, mIdx) in getAssetMedia(a).all" :key="mIdx"
|
||
class="thumb-item" @click.stop="onThumbClick(a, m)">
|
||
<img :src="m.thumb || m.url" loading="lazy">
|
||
<span v-if="m.type === 'video'" class="thumb-badge">▶</span>
|
||
<span v-else-if="m.type === 'panorama'" class="thumb-badge">360°</span>
|
||
</div>
|
||
</div>
|
||
-->
|
||
<div class="actions">
|
||
<button class="btn-detail" @click.stop="goToDetail(a)">查看详情</button>
|
||
<button class="btn-media" @click.stop="openMediaPanel(a)">影像资料</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="loadingMore" class="loading-tip">加载中...</div>
|
||
<div v-if="allLoaded && assetList.length > 0" class="loading-tip">已加载全部 {{total}} 条</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 相册弹框 -->
|
||
<div v-if="lightbox.visible" class="lightbox-overlay" @keyup.escape="closeLightbox()" tabindex="0">
|
||
<button class="lightbox-close" @click="closeLightbox()">✕</button>
|
||
<button class="lightbox-nav lightbox-prev" @click="lightboxNav(-1)">‹</button>
|
||
<img class="lightbox-img" :src="lightbox.images[lightbox.index]">
|
||
<button class="lightbox-nav lightbox-next" @click="lightboxNav(1)">›</button>
|
||
<div class="lightbox-counter">{{lightbox.index + 1}} / {{lightbox.images.length}}</div>
|
||
</div>
|
||
|
||
<!-- 视频播放弹框 -->
|
||
<div v-if="videoOverlay.visible" class="video-overlay" @keyup.escape="closeVideoPlayer()" tabindex="0">
|
||
<button class="video-close" @click="closeVideoPlayer()">✕</button>
|
||
<video class="video-player" :src="videoOverlay.url" controls autoplay></video>
|
||
</div>
|
||
|
||
<!-- 影像资料弹框 -->
|
||
<div v-if="mediaPanel.visible" class="media-panel-overlay" @click.self="closeMediaPanel()">
|
||
<div class="media-panel">
|
||
<div class="media-panel-header">
|
||
<span class="media-panel-title">{{mediaPanel.assetName || '影像资料'}}</span>
|
||
<button class="media-panel-close" @click="closeMediaPanel()">✕</button>
|
||
</div>
|
||
<div class="media-tabs">
|
||
<div class="media-tab" :class="{active: mediaPanel.tab === 'images'}" @click="mediaPanel.tab = 'images'">图片</div>
|
||
<div class="media-tab" :class="{active: mediaPanel.tab === 'videos'}" @click="mediaPanel.tab = 'videos'">视频</div>
|
||
<!-- <div class="media-tab" :class="{active: mediaPanel.tab === 'files'}" @click="mediaPanel.tab = 'files'">附件</div>-->
|
||
</div>
|
||
<div class="media-panel-body">
|
||
<!-- 图片页签 -->
|
||
<div v-if="mediaPanel.tab === 'images'">
|
||
<div v-if="mediaPanel.media.images.length || mediaPanel.media.panoramas.length" class="media-img-grid">
|
||
<div v-for="(img, idx) in mediaPanel.media.images" :key="'img-'+idx"
|
||
class="media-img-item" @click="openLightboxFromPanel(idx)">
|
||
<img :src="img" loading="lazy">
|
||
</div>
|
||
<div v-for="(p, idx) in mediaPanel.media.panoramas" :key="'pno-'+idx"
|
||
class="media-img-item" @click="openPanoViewer(p)">
|
||
<img :src="p" loading="lazy">
|
||
<span class="pano-badge">360°</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="media-empty">暂无图片</div>
|
||
</div>
|
||
<!-- 视频页签 -->
|
||
<div v-if="mediaPanel.tab === 'videos'">
|
||
<div v-if="mediaPanel.media.videos.length" class="media-video-grid">
|
||
<div v-for="(v, idx) in mediaPanel.media.videos" :key="'vid-'+idx"
|
||
class="media-video-item" @click="openVideoPlayer(v)">
|
||
<img :src="mediaPanel.media.videoThumbs[idx] || ''" loading="lazy">
|
||
<div class="play-icon">▶</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="media-empty">暂无视频</div>
|
||
</div>
|
||
<!-- 附件页签 -->
|
||
<div v-if="mediaPanel.tab === 'files'">
|
||
<div v-if="mediaPanel.media.files.length" class="media-file-list">
|
||
<div v-for="(f, idx) in mediaPanel.media.files" :key="'file-'+idx"
|
||
class="media-file-item" @click="downloadFile(f)">
|
||
<div class="media-file-icon">{{getFileExt(f.name)}}</div>
|
||
<div class="media-file-info">
|
||
<div class="media-file-name">{{f.name || '附件' + (idx+1)}}</div>
|
||
<div class="media-file-size">{{f.size || ''}}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="media-empty">暂无附件</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 360全景弹框 -->
|
||
<div v-if="panoOverlay.visible" class="pano-overlay" @keyup.escape="closePanoViewer()" tabindex="0">
|
||
<button class="pano-close" @click="closePanoViewer()">✕</button>
|
||
<div class="pano-viewer" ref="panoViewer"
|
||
:style="{backgroundImage: 'url(' + panoOverlay.url + ')'}"
|
||
@mousedown="panoDragStart" @mousemove="panoDragMove" @mouseup="panoDragEnd" @mouseleave="panoDragEnd"
|
||
@touchstart.passive="panoDragStart" @touchmove.prevent="panoDragMove" @touchend="panoDragEnd">
|
||
</div>
|
||
<div class="pano-hint">拖动鼠标或触摸滑动查看全景</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
/**
|
||
* 资产地图主应用
|
||
* 功能:展示资产标点、按经纬度分组、加载/高亮边界、多资产切换、筛选查询
|
||
*/
|
||
new Vue({
|
||
el: "#app",
|
||
data: {
|
||
// ========== 基础配置 ==========
|
||
host: 'http://sz.esdci.cn:52100', // 后端服务地址
|
||
|
||
// ========== 地图实例与图层 ==========
|
||
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, // 是否已执行过查询(控制空状态提示)
|
||
pageNum: 1, // 当前页码
|
||
pageSize: 20, // 每页条数
|
||
loadingMore: false, // 是否正在加载更多(防止重复触发)
|
||
allLoaded: false, // 是否已加载全部
|
||
|
||
// ========== 标点与边界管理 ==========
|
||
isMobile: false, // 是否移动端
|
||
markerMap: {}, // 标点映射:lat_lng_key -> L.marker(用于列表点击定位标点)
|
||
markerBoundLayers: {}, // 边界图层映射:markerKey -> L.layerGroup(每个标点独立管理边界)
|
||
|
||
// ========== 选中状态 ==========
|
||
activeAssetId: null, // 当前选中的资产ID(用于列表高亮)
|
||
activeMarker: null, // 当前高亮的标点对象
|
||
_syncFromMap: false, // 防止地图点击与列表选中双向触发的标志
|
||
isAssetView: false, // 是否为URL穿透的单资产查看模式(禁用查询按钮)
|
||
|
||
// ========== 媒体浏览 ==========
|
||
lightbox: { visible: false, images: [], index: 0 },
|
||
videoOverlay: { visible: false, url: '' },
|
||
panoOverlay: { visible: false, url: '' },
|
||
_panoState: { dragging: false, startX: 0, startPos: 50 },
|
||
mediaPanel: {
|
||
visible: false,
|
||
assetName: '',
|
||
tab: 'images',
|
||
media: { images: [], videos: [], videoThumbs: [], panoramas: [], files: [] }
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 生命周期:初始化
|
||
* 1. 检测设备类型
|
||
* 2. 初始化地图
|
||
* 3. 解析URL参数,决定是定位单个资产还是加载筛选列表
|
||
*/
|
||
mounted() {
|
||
this.checkDevice();
|
||
window.addEventListener('resize', () => this.checkDevice());
|
||
this.initMap();
|
||
|
||
// 全局键盘事件:Escape关闭弹框
|
||
var self = this;
|
||
window.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape' || e.keyCode === 27) {
|
||
if (self.lightbox.visible) self.closeLightbox();
|
||
else if (self.videoOverlay.visible) self.closeVideoPlayer();
|
||
else if (self.panoOverlay.visible) self.closePanoViewer();
|
||
else if (self.mediaPanel.visible) self.closeMediaPanel();
|
||
}
|
||
// 相册左右翻页
|
||
if (self.lightbox.visible) {
|
||
if (e.key === 'ArrowLeft') self.lightboxNav(-1);
|
||
else if (e.key === 'ArrowRight') self.lightboxNav(1);
|
||
}
|
||
});
|
||
|
||
// URL参数 assetId 支持从其他页面穿透定位到指定资产
|
||
var params = new URLSearchParams(window.location.search);
|
||
var assetId = params.get('assetId');
|
||
if (assetId) {
|
||
this.isAssetView = true;
|
||
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="./blue.png" style="width:48px;height:48px;">',
|
||
className: 'custom-marker-icon',
|
||
iconSize: [48, 48],
|
||
iconAnchor: [24, 24]
|
||
});
|
||
this.markerIconRed = L.divIcon({
|
||
html: '<img src="./red.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.pageNum = 1; this.allLoaded = false; this.loadingMore = false;
|
||
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.pageNum = 1;
|
||
this.allLoaded = false;
|
||
this.loadingMore = false;
|
||
// 清空现有图层
|
||
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: this.pageSize
|
||
};
|
||
|
||
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.allLoaded = self.assetList.length >= self.total;
|
||
self.buildMarkers();
|
||
self.bindScrollListener();
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 绑定资产列表滚动事件,触底时加载下一页
|
||
*/
|
||
bindScrollListener() {
|
||
var self = this;
|
||
var container = this.$refs.assetListContainer;
|
||
if (!container || self._scrollBound) return;
|
||
self._scrollBound = true;
|
||
container.addEventListener('scroll', function() {
|
||
if (self.loadingMore || self.allLoaded) return;
|
||
// 距底部 50px 时触发加载
|
||
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 50) {
|
||
self.loadMore();
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 加载下一页资产数据,追加到 assetList 并刷新标点
|
||
*/
|
||
loadMore() {
|
||
if (this.loadingMore || this.allLoaded) return;
|
||
this.loadingMore = true;
|
||
this.pageNum++;
|
||
var self = this;
|
||
var body = {
|
||
owner: this.filterOwner,
|
||
level3: this.filterCatagory,
|
||
keyword: this.keyword,
|
||
pageNum: this.pageNum,
|
||
pageSize: this.pageSize
|
||
};
|
||
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) {
|
||
var newRecords = d.data.records || [];
|
||
self.assetList = self.assetList.concat(newRecords);
|
||
self.allLoaded = self.assetList.length >= self.total;
|
||
self.buildMarkers();
|
||
}
|
||
self.loadingMore = false;
|
||
}).catch(function() { self.loadingMore = false; });
|
||
},
|
||
|
||
// =====================================================================
|
||
// 标点构建(按经纬度分组)
|
||
// =====================================================================
|
||
|
||
/**
|
||
* 按经纬度分组构建标点
|
||
* 核心逻辑:
|
||
* 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];
|
||
// 判断该组是否有问题资产(troubledAssets 值为"是")
|
||
var hasProblem = group.assets.some(function(a) {
|
||
return a.troubledAssets === '是';
|
||
});
|
||
var icon = hasProblem ? self.markerIconRed : self.markerIcon;
|
||
var m = L.marker([group.lat, group.lng], { icon: icon });
|
||
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;
|
||
// 判断是否问题资产
|
||
var hasProblem = m._assets && m._assets.some(function(a) {
|
||
return a.troubledAssets === '是';
|
||
});
|
||
if (hasProblem) {
|
||
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))';
|
||
} else {
|
||
img.style.filter = 'drop-shadow(0 0 6px #3498db) drop-shadow(0 0 12px #3498db) drop-shadow(0 0 20px rgba(52,152,219,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;
|
||
let url = this.host + "/seeyon/rest/assetsRegion/getDetailBaseUrlByAssetsType";
|
||
|
||
// 过滤 null、undefined、空字符串
|
||
const assetsType = item.assetType;
|
||
if (assetsType != null && assetsType !== '') {
|
||
url += "?assetsType=" + encodeURIComponent(assetsType);
|
||
}
|
||
|
||
fetch(url)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.code === 0 && d.data) {
|
||
window.open(host + d.data.replace(/\{assetsId\}/g, item.id));
|
||
}
|
||
});
|
||
},
|
||
|
||
// =====================================================================
|
||
// 媒体浏览
|
||
// =====================================================================
|
||
|
||
/** 获取资产的所有媒体资源,返回 {images, videos, panoramas, all} */
|
||
getAssetMedia(asset) {
|
||
// 后端返回的是 FileItemVO 对象数组:[{previewUrl, name, ...}]
|
||
// 提取 previewUrl 作为图片/视频URL
|
||
var images = this._extractUrls(asset.images);
|
||
var videos = this._extractUrls(asset.videos);
|
||
var panoramas = this._extractUrls(asset.panoramas);
|
||
|
||
var all = [];
|
||
images.forEach(function(url) { all.push({ type: 'image', url: url, thumb: url }); });
|
||
videos.forEach(function(url) { all.push({ type: 'video', url: url, thumb: url }); });
|
||
panoramas.forEach(function(url) { all.push({ type: 'panorama', url: url, thumb: url }); });
|
||
return { images: images, videos: videos, panoramas: panoramas, all: all };
|
||
},
|
||
|
||
/**
|
||
* 从FileItemVO数组中提取previewUrl列表
|
||
* 输入: [{previewUrl: "http://...", name: "xxx"}, ...]
|
||
* 输出: ["http://...", ...]
|
||
*/
|
||
_extractUrls: function(items) {
|
||
if (!items || !Array.isArray(items)) return [];
|
||
return items
|
||
.map(function(item) { return item.previewUrl || item.url || ''; })
|
||
.filter(function(url) { return url.length > 0; });
|
||
},
|
||
|
||
/** 打开影像资料弹框 */
|
||
openMediaPanel(asset) {
|
||
var media = this.getAssetMedia(asset);
|
||
this.mediaPanel.assetName = asset.assetName || asset.assetCode || '';
|
||
this.mediaPanel.tab = 'images';
|
||
this.mediaPanel.media = {
|
||
images: media.images,
|
||
videos: media.videos,
|
||
videoThumbs: media.videos.map(function(v, i) {
|
||
return 'https://picsum.photos/seed/vid' + (asset.assetCode || '') + i + '/320/180';
|
||
}),
|
||
panoramas: media.panoramas,
|
||
files: this._extractUrls(asset.attachments).map(function(url, i) {
|
||
var name = url.split('/').pop() || ('附件' + (i + 1));
|
||
return { name: name, url: url, size: '' };
|
||
})
|
||
};
|
||
this.mediaPanel.visible = true;
|
||
},
|
||
|
||
/** 关闭影像资料弹框 */
|
||
closeMediaPanel() {
|
||
this.mediaPanel.visible = false;
|
||
},
|
||
|
||
/** 从影像资料弹框中打开相册(只包含普通图片) */
|
||
openLightboxFromPanel(idx) {
|
||
var images = this.mediaPanel.media.images;
|
||
if (!images.length) return;
|
||
this.lightbox.images = images;
|
||
this.lightbox.index = idx;
|
||
this.lightbox.visible = true;
|
||
},
|
||
|
||
/** 获取文件扩展名(用于显示图标) */
|
||
getFileExt(name) {
|
||
if (!name) return '?';
|
||
var ext = name.split('.').pop().toUpperCase();
|
||
if (ext.length > 4) ext = ext.substring(0, 4);
|
||
return ext;
|
||
},
|
||
|
||
/** 下载附件 */
|
||
downloadFile(file) {
|
||
if (!file || !file.url) return;
|
||
var a = document.createElement('a');
|
||
a.href = file.url;
|
||
a.download = file.name || '';
|
||
a.target = '_blank';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
},
|
||
|
||
/** 打开相册弹框 */
|
||
openLightbox(asset, startIdx) {
|
||
var media = this.getAssetMedia(asset);
|
||
if (!media.images.length) return;
|
||
this.lightbox.images = media.images;
|
||
this.lightbox.index = startIdx || 0;
|
||
this.lightbox.visible = true;
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
var el = document.querySelector('.lightbox-overlay');
|
||
if (el) el.focus();
|
||
});
|
||
},
|
||
|
||
/** 关闭相册弹框 */
|
||
closeLightbox() {
|
||
this.lightbox.visible = false;
|
||
this.lightbox.images = [];
|
||
this.lightbox.index = 0;
|
||
},
|
||
|
||
/** 相册前后翻页 */
|
||
lightboxNav(delta) {
|
||
var len = this.lightbox.images.length;
|
||
if (!len) return;
|
||
this.lightbox.index = (this.lightbox.index + delta + len) % len;
|
||
},
|
||
|
||
/** 缩略图点击:根据类型打开对应弹框 */
|
||
onThumbClick(asset, mediaItem) {
|
||
if (mediaItem.type === 'image') {
|
||
var media = this.getAssetMedia(asset);
|
||
var idx = media.images.indexOf(mediaItem.url);
|
||
this.openLightbox(asset, idx >= 0 ? idx : 0);
|
||
} else if (mediaItem.type === 'video') {
|
||
this.openVideoPlayer(mediaItem.url);
|
||
} else if (mediaItem.type === 'panorama') {
|
||
this.openPanoViewer(mediaItem.url);
|
||
}
|
||
},
|
||
|
||
/** 打开视频播放弹框 */
|
||
openVideoPlayer(url) {
|
||
this.videoOverlay.url = url;
|
||
this.videoOverlay.visible = true;
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
var el = document.querySelector('.video-overlay');
|
||
if (el) el.focus();
|
||
});
|
||
},
|
||
|
||
/** 关闭视频播放弹框 */
|
||
closeVideoPlayer() {
|
||
this.videoOverlay.visible = false;
|
||
this.videoOverlay.url = '';
|
||
},
|
||
|
||
/** 打开360全景查看器 */
|
||
openPanoViewer(url) {
|
||
this.panoOverlay.url = url;
|
||
this.panoOverlay.visible = true;
|
||
this._panoState = { dragging: false, startX: 0, startPos: 50 };
|
||
var self = this;
|
||
this.$nextTick(function() {
|
||
var viewer = self.$refs.panoViewer;
|
||
if (viewer) viewer.style.backgroundPositionX = '50%';
|
||
var el = document.querySelector('.pano-overlay');
|
||
if (el) el.focus();
|
||
});
|
||
},
|
||
|
||
/** 关闭360全景查看器 */
|
||
closePanoViewer() {
|
||
this.panoOverlay.visible = false;
|
||
this.panoOverlay.url = '';
|
||
},
|
||
|
||
/** 360全景拖拽开始 */
|
||
panoDragStart(e) {
|
||
this._panoState.dragging = true;
|
||
this._panoState.startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
||
var viewer = e.currentTarget;
|
||
var pos = parseFloat(viewer.style.backgroundPositionX) || 50;
|
||
this._panoState.startPos = pos;
|
||
viewer.classList.add('dragging');
|
||
},
|
||
|
||
/** 360全景拖拽移动 */
|
||
panoDragMove(e) {
|
||
if (!this._panoState.dragging) return;
|
||
var clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
||
var dx = clientX - this._panoState.startX;
|
||
var viewer = e.currentTarget;
|
||
var newPos = this._panoState.startPos + (dx / viewer.offsetWidth) * 100;
|
||
newPos = Math.max(0, Math.min(100, newPos));
|
||
viewer.style.backgroundPositionX = newPos + '%';
|
||
},
|
||
|
||
/** 360全景拖拽结束 */
|
||
panoDragEnd(e) {
|
||
this._panoState.dragging = false;
|
||
if (e && e.currentTarget) e.currentTarget.classList.remove('dragging');
|
||
}
|
||
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|