Files
AssetsMap/assetsMap/map.html
2026-07-22 17:23:49 +08:00

1515 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>资产地图</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:"Microsoft YaHei",Arial,sans-serif; height:100vh; display:flex; overflow:hidden; }
#app { display:flex; width:100%; height:100vh; }
#mapWrap { flex:1; position:relative; min-width:0; height:100%; }
#map { height:100%; width:100%; }
#sidePanel {
width:380px; height:100vh; background:#fff;
border-left:1px solid #e0e0e0;
display:flex; flex-direction:column; flex-shrink:0;
position:relative; z-index:10000;
box-shadow:-2px 0 12px rgba(0,0,0,0.08);
}
.filter-section {
padding:12px 16px; border-bottom:2px solid #eee; background:#fff; flex-shrink:0;
}
.filter-section .title {
font-size:14px; font-weight:bold; color:#333; margin-bottom:10px;
display:flex; justify-content:space-between; align-items:center;
}
.filter-section .title button {
background:none; border:none; color:#e74c3c; font-size:18px; cursor:pointer;
}
.filter-group { margin-bottom:8px; }
.filter-group label { display:block; font-size:12px; color:#888; margin-bottom:3px; }
.filter-group select, .filter-group input[type="text"] {
width:100%; padding:8px 10px; border:1px solid #ddd; border-radius:6px;
font-size:13px; background:#fff;
}
.filter-group select:focus, .filter-group input:focus {
outline:none; border-color:#e74c3c; box-shadow:0 0 0 2px rgba(231,76,60,0.1);
}
.filter-actions { display:flex; gap:8px; margin-top:10px; }
.btn {
flex:1; padding:9px 0; border:none; border-radius:6px;
cursor:pointer; font-size:13px; font-weight:bold; transition:all 0.2s;
}
.btn-primary { background:#e74c3c; color:#fff; }
.btn-primary:hover { background:#c0392b; }
.btn-reset { background:#f5f5f5; color:#666; border:1px solid #e0e0e0; }
.asset-section {
flex:1; overflow-y:auto; display:flex; flex-direction:column;
}
.asset-section .section-header {
padding:10px 16px; font-size:13px; font-weight:bold; color:#333;
background:#f7f8fa; border-bottom:1px solid #eee; flex-shrink:0;
}
.asset-section .section-header b { color:#e74c3c; margin:0 2px; }
.asset-list { flex:1; overflow-y:auto; }
.asset-item {
padding:12px 16px; border-bottom:1px solid #f0f0f0; cursor:pointer;
border-left:3px solid transparent;
}
.asset-item:hover { background:#f8f9fa; }
.asset-item.active { background:#fdedec; border-left-color:#e74c3c; }
.asset-item .name { font-size:14px; font-weight:bold; color:#333; }
.asset-item .info { font-size:12px; color:#888; margin-top:3px; line-height:1.6; }
.asset-item .actions { display:flex; gap:6px; margin-top:8px; }
.asset-item .actions button {
padding:5px 10px; border:1px solid #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>