Files
AssetsMap/assetsMap/map.html

1515 lines
59 KiB
HTML
Raw Normal View History

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