更新样地和样木弹窗逻辑

This commit is contained in:
2025-11-28 18:54:22 +08:00
parent df06a9e60e
commit 8968a68c6d
5 changed files with 1220 additions and 506 deletions

View File

@@ -36,21 +36,16 @@ export const fetchPlotGeoJSON = async () => {
return { return {
...feature, ...feature,
properties: { properties: {
// 保留原始属性
...p, ...p,
// --- 关键映射区域 (根据你的数据库字段修改左边的值) ---
name: p.xb_name || p.name || "未命名图斑", name: p.xb_name || p.name || "未命名图斑",
// 这里的映射决定了地图颜色!确保数据库里有 "乔木林"/"灌木林" 等值
// 如果数据库存的是代码(1,2,3),这里需要写转换逻辑
type: p.forest_type || p.type || "其他", type: p.forest_type || p.type || "其他",
area: p.area_mu || 0, area: p.area_mu || 0,
collector: p.manager || "未知人员", collector: p.manager || "未知人员",
// --- 图片/视频处理 ---
// 假设数据库里存的是 "url1,url2" 这样的字符串
images: p.image_urls ? p.image_urls.split(',') : [], images: p.image_urls ? p.image_urls.split(',') : [],
videos: p.video_urls ? p.video_urls.split(',') : [], videos: p.video_urls ? p.video_urls.split(',') : [],
}, },

View File

@@ -5,7 +5,7 @@
</div> </div>
<div id="map" ref="mapContainer" class="map-container"></div> <div id="map" ref="mapContainer" class="map-container"></div>
<div id="dateSelectItem" class="date-panel"> <div id="dateSelectItem" class="date-panel">
<span class="date-label">数据日期</span> <span class="date-label">选择日期</span>
<el-date-picker <el-date-picker
v-model="selectedDate" v-model="selectedDate"
type="date" type="date"
@@ -16,6 +16,23 @@
@change="handleDateChange" @change="handleDateChange"
size="default" size="default"
/> />
<button
v-if="!hasDateMarkers && selectedDate"
class="action-btn show-btn"
@click="handleShowData"
title="加载当前日期的统计数据"
>
<el-icon><Search /></el-icon> 显示数据
</button>
<button
v-if="hasDateMarkers"
class="action-btn clear-btn"
@click="clearDateMarkers"
title="清除地图上的标注"
>
<el-icon><Close /></el-icon> 清除数据
</button>
</div> </div>
<div class="legend-panel"> <div class="legend-panel">
@@ -23,7 +40,7 @@
<div class="control-item group-item"> <div class="control-item group-item">
<label class="group-header"> <label class="group-header">
<input type="checkbox" v-model="isStatLayerVisible" /> <input type="checkbox" v-model="isStatLayerVisible" />
<span>统计数据 ({{ currentLevel === 'district' ? '区级' : '街道' }})</span> <span>统计数据 </span>
</label> </label>
<div v-if="isStatLayerVisible" class="group-body"> <div v-if="isStatLayerVisible" class="group-body">
@@ -36,13 +53,13 @@
<div class="control-item"> <div class="control-item">
<label> <label>
<input type="checkbox" v-model="isPlotLayerVisible" /> <input type="checkbox" v-model="isPlotLayerVisible" />
所有图斑 小班图层
</label> </label>
</div> </div>
<div class="control-item"> <div class="control-item">
<label> <label>
<input type="checkbox" /> <input type="checkbox" v-model="isSampleLayerVisible" />
样地图 样地图
</label> </label>
</div> </div>
@@ -60,7 +77,6 @@
<script setup> <script setup>
import { onMounted, onBeforeUnmount, ref, watch } from "vue"; import { onMounted, onBeforeUnmount, ref, watch } from "vue";
import { Close } from "@element-plus/icons-vue";
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { TDT_KEY } from "../assets/tdt_key.js"; import { TDT_KEY } from "../assets/tdt_key.js";
@@ -70,12 +86,15 @@ import { samplePlots, sideTrees } from "../assets/mockPlotData.js";
import PlotPopup from "./PlotPopup.vue"; import PlotPopup from "./PlotPopup.vue";
import SamplePopup from "./SamplePopup.vue"; import SamplePopup from "./SamplePopup.vue";
import bbox from "@turf/bbox"; import bbox from "@turf/bbox";
import { Close, Search } from "@element-plus/icons-vue";
const popupVisible = ref(false); const popupVisible = ref(false);
const popupData = ref({}); const popupData = ref({});
const popupPos = ref({ x: 0, y: 0 }); const popupPos = ref({ x: 0, y: 0 });
const isPlotLayerVisible = ref(false); const isPlotLayerVisible = ref(false);
const isStatLayerVisible = ref(true); const isStatLayerVisible = ref(true);
const isSampleLayerVisible = ref(false);
// 天津市行政区划数据(区级) // 天津市行政区划数据(区级)
const districtUrl ="https://geo.datav.aliyun.com/areas_v3/bound/120000_full.json"; const districtUrl ="https://geo.datav.aliyun.com/areas_v3/bound/120000_full.json";
@@ -96,6 +115,14 @@ const mapContainer = ref(null);
let map = null; let map = null;
const legendItems = ref([]); const legendItems = ref([]);
let allStreetsData = null; let allStreetsData = null;
const districtCountCache=ref({});
let hoverPopup=null;
const dateMarkers = ref([]); // 存储 Marker 实例
const hasDateMarkers = ref(false); // 控制清除按钮显示
const districtCenters = {};
const SAMPLE_SOURCE_ID = "sample-wms-source";
const SAMPLE_LAYER_ID = "sample-raster-layer";
const SAMPLE_LAYER_NAME = "lydc:YDTB";
watch(isStatLayerVisible, () => { watch(isStatLayerVisible, () => {
updateStatLayerState(); updateStatLayerState();
}); });
@@ -107,17 +134,112 @@ watch(isPlotLayerVisible, (newVal) => {
hidePlotLayer(); hidePlotLayer();
} }
}); });
watch(isSampleLayerVisible, (newVal) => {
if (newVal) {
showSamplePlotLayer();
} else {
hideSamplePlotLayer();
}
});
const selectedDate = ref(new Date().toISOString().split('T')[0]); // 默认今天 const selectedDate = ref(new Date().toISOString().split('T')[0]); // 默认今天
// ✅ 新增:日期变更处理函数 function calculateDistrictCenters(geojson) {
function handleDateChange(val) { if (!geojson || !geojson.features) return;
geojson.features.forEach(feature => {
const name = feature.properties.name;
if (name) {
// 使用 bbox 计算几何中心
const [minX, minY, maxX, maxY] = bbox(feature);
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
districtCenters[name] = [centerX, centerY];
}
});
console.log("📍 行政区中心点计算完毕:", districtCenters);
}
// --- ✅ 修改:日期变更处理 ---
async function handleDateChange(val) {
if (!val) return;
console.log("📅 日期已切换为:", val); console.log("📅 日期已切换为:", val);
// 在这里添加你的逻辑,例如: // 1. 先清除旧的标注
// 1. 重新请求统计接口 clearDateMarkers();
// fetchDistrictStatus(val);
// 2. 刷新 WMS 图层 (如果 WMS 支持 TIME 参数) // 2. 请求新数据
// refreshWmsLayer(val); await fetchDateStats(val);
}
// --- ✅ 新增:请求日期统计数据 ---
async function fetchDateStats(dateStr) {
try {
const url = `/api/stats/date?date=${dateStr}`;
const res = await fetch(url);
const json = await res.json();
if (json.code === 0 && json.data && json.data.length > 0) {
renderDateMarkers(json.data);
} else {
console.log("该日期无数据");
// Element Plus 的消息提示 (如果项目里有引入 ElMessage)
// ElMessage.warning("该日期无统计数据");
}
} catch (error) {
console.error("日期统计请求失败:", error);
}
}
// --- ✅ 新增:渲染标注 ---
function renderDateMarkers(dataList) {
// 遍历后端返回的数据
dataList.forEach(item => {
const name = item.district;
const center = districtCenters[name];
// 如果能找到该区的坐标
if (center) {
// 1. 创建自定义 HTML 元素
const el = document.createElement('div');
el.className = 'date-stat-marker';
el.innerHTML = `
<div class="marker-title">${name}</div>
<div class="marker-row">完成小班: <b>${item.plotcount}</b></div>
<div class="marker-row">完成样地: <b>${item.sampleplotcount}</b></div>
`;
// 2. 创建 MapLibre Marker
const marker = new maplibregl.Marker({
element: el,
anchor: 'bottom', // 标注的“脚”对准中心点
offset: [0, -10] // 稍微往上提一点,不遮挡文字
})
.setLngLat(center)
.addTo(map);
// 3. 存入数组方便管理
dateMarkers.value.push(marker);
}
});
if (dateMarkers.value.length > 0) {
hasDateMarkers.value = true;
}
}
// ✅ 新增:点击“显示数据”按钮的处理函数
async function handleShowData() {
if (!selectedDate.value) {
alert("请先选择日期");
return;
}
// 手动触发请求
await fetchDateStats(selectedDate.value);
}
// --- ✅ 新增:清除标注 ---
function clearDateMarkers() {
dateMarkers.value.forEach(marker => marker.remove());
dateMarkers.value = [];
hasDateMarkers.value = false;
} }
// ✅ 可选:禁用未来日期 // ✅ 可选:禁用未来日期
@@ -172,7 +294,23 @@ async function fetchDistrictStatus(){
console.error("获取区级指标数据失败:",error); console.error("获取区级指标数据失败:",error);
} }
} }
async function fetchDistrictCount(districtName){
if (districtCountCache.value[districtName]){
return districtCountCache.value[districtName];
}
try {
const url=`/api/stats/district/count?district=${encodeURIComponent(districtName)}`;
const res=await fetch(url);
const json=await res.json();
if(json.code===0&&json.data){
districtCountCache.value[districtName]=json.data;
return json.data;
}
}catch (error){
console.log(`获取${districtName}图斑数量失败:`,error);
}
return null;
}
// 街道指标 // 街道指标
const streetIndicatorData = ref({ const streetIndicatorData = ref({
完成状态:{}, 完成状态:{},
@@ -293,6 +431,19 @@ function processPlotData(data) {
videos // 只有视频链接的数组 videos // 只有视频链接的数组
}; };
} }
async function fetchPlotStats(rootId, databaseName) {
try {
const url = `/api/stats/plot/count?rootId=${rootId}&databaseName=${encodeURIComponent(databaseName)}`;
const res = await fetch(url);
const json = await res.json();
if (json.code === 0) {
return json.data;
}
} catch (error) {
console.error("获取小班下样地统计失败:", error);
}
return null;
}
// 定义点击区级多边形的处理函数 // 定义点击区级多边形的处理函数
async function handleDistrictClick(e) { async function handleDistrictClick(e) {
const districtName = e.features[0].properties.name; const districtName = e.features[0].properties.name;
@@ -364,6 +515,7 @@ onMounted(async () => {
const res = await fetch(districtUrl); const res = await fetch(districtUrl);
const geojson = await res.json(); const geojson = await res.json();
calculateDistrictCenters(geojson);
map.on("load", () => { map.on("load", () => {
map.addSource("tianjin", { type: "geojson", data: geojson }); map.addSource("tianjin", { type: "geojson", data: geojson });
@@ -401,13 +553,101 @@ onMounted(async () => {
"text-halo-width": 2 // 描边宽度 "text-halo-width": 2 // 描边宽度
} }
}); });
updateMapColors(); updateMapColors();
// 点击区多边形,下钻街道级 // 点击区多边形,下钻街道级
map.off("click", "tianjin-fill", handleDistrictClick); // map.off("click", "tianjin-fill", handleDistrictClick);
map.on("click", "tianjin-fill", handleDistrictClick); // map.on("click", "tianjin-fill", handleDistrictClick);
hoverPopup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
className: 'district-hpver-popup',
maxWidth: '300px'
});
let lastHoveredDistrict = null;
// 鼠标移动事件
map.on('mousemove', 'tianjin-fill', async (e) => {
// 1. 基础校验
if (currentLevel.value !== 'district' || !isStatLayerVisible.value) return;
const features = map.queryRenderedFeatures(e.point, { layers: ['tianjin-fill'] });
if (!features.length) {
// 如果虽然在图层范围内但没取到要素(极端情况),也视为移出
hoverPopup.remove();
lastHoveredDistrict = null;
map.getCanvas().style.cursor = "";
return;
}
const districtName = features[0].properties.name;
// 2. 始终更新:鼠标样式和弹窗位置(让弹窗跟手)
map.getCanvas().style.cursor = "pointer";
hoverPopup.setLngLat(e.lngLat);
// 🔥🔥🔥 核心修复:如果鼠标还在同一个区内,直接返回,不执行后面逻辑!
if (lastHoveredDistrict === districtName) {
return;
}
// 3. 只有当进入新区域时,才执行下面的代码
lastHoveredDistrict = districtName; // 更新当前区名
// 先显示弹窗(加载状态),提升交互响应速度
if (!hoverPopup.isOpen()) hoverPopup.addTo(map);
// 检查缓存
let countData = districtCountCache.value[districtName];
if (!countData) {
// 缓存没有,显示加载中
hoverPopup.setHTML(`
<div class="hover-content">
<div class="hover-title">${districtName}</div>
<div class="hover-loading">暂无数据</div>
</div>
`);
// 发起请求
countData = await fetchDistrictCount(districtName);
}
// 4. 数据获取回来后,再次确认:鼠标是否还停留在该区?
// (防止网络慢,请求回来时鼠标已经移到别的区了,导致弹窗内容错乱)
if (lastHoveredDistrict === districtName && countData) {
const htmlContent = `
<div class="hover-content">
<div class="hover-title">${districtName}</div>
<div class="hover-row">
<span class="label">内业数量:</span>
<span class="value val-ny">${countData.ny || 0}</span>
</div>
<div class="hover-row">
<span class="label">外业完成:</span>
<span class="value val-wy">${countData.wy || 0}</span>
</div>
<div class="hover-row">
<span class="label">样地数量:</span>
<span class="value val-yd">${countData.yd || 0}</span>
</div>
</div>
`;
hoverPopup.setHTML(htmlContent);
}
});
// 鼠标移出事件
map.on('mouseleave', 'tianjin-fill', () => {
map.getCanvas().style.cursor = "";
// 清空记录
lastHoveredDistrict = null;
if (hoverPopup) {
hoverPopup.remove();
}
});
}); });
}); });
@@ -599,7 +839,7 @@ async function handlePlotClick(e) {
].join(","); ].join(",");
const baseUrl = "/geoserver/lydc/wms"; const baseUrl = "/geoserver/lydc/wms";
const layerName = "lydc:NYTB"; const layerName = "lydc:NYTB4";
const params = new URLSearchParams({ const params = new URLSearchParams({
SERVICE: "WMS", SERVICE: "WMS",
@@ -633,6 +873,7 @@ async function handlePlotClick(e) {
if (!wmsRes.ok) throw new Error(`HTTP ${wmsRes.status}`); if (!wmsRes.ok) throw new Error(`HTTP ${wmsRes.status}`);
const wmsData = await wmsRes.json(); const wmsData = await wmsRes.json();
console.log("WMS GetFeatureInfo 返回:", wmsData);
if (wmsData.features && wmsData.features.length > 0) { if (wmsData.features && wmsData.features.length > 0) {
const wmsProp = wmsData.features[0].properties; const wmsProp = wmsData.features[0].properties;
@@ -644,14 +885,19 @@ async function handlePlotClick(e) {
const cunCode = String(wmsProp.cun || wmsProp.CUN); const cunCode = String(wmsProp.cun || wmsProp.CUN);
xiang = cunCode.substring(0, 9); xiang = cunCode.substring(0, 9);
} }
const rootId = wmsProp.id || wmsProp.ID;
const databaseName = wmsProp.database_n || wmsProp.DATABASE_N;
if (nyxbh && xiang) { if (nyxbh && xiang&&rootId&&databaseName) {
const detailData = await fetchPlotData(nyxbh, xiang); const [plotData, sampleData] = await Promise.all([
if (detailData) { fetchPlotData(nyxbh, xiang),
fetchPlotStats(rootId, databaseName)
]);
if (plotData&&sampleData) {
const canvasRect = map.getCanvas().getBoundingClientRect(); const canvasRect = map.getCanvas().getBoundingClientRect();
const pos = map.project(e.lngLat); const pos = map.project(e.lngLat);
popupPos.value = { x: pos.x + canvasRect.left, y: pos.y + canvasRect.top }; popupPos.value = { x: pos.x + canvasRect.left, y: pos.y + canvasRect.top };
popupData.value = detailData; popupData.value = {plotData, sampleData };
popupVisible.value = true; popupVisible.value = true;
} else { } else {
alert("未查询到详细数据"); alert("未查询到详细数据");
@@ -678,9 +924,9 @@ function handleMapClickClosePopup(e) {
let cachedPlotData = null; let cachedPlotData = null;
function showPlotLayer() { function showPlotLayer() {
// 3. 如果 Source 不存在,添加 WMS 源 // 1. 添加 Source (保持不变)
if (!map.getSource("plot-wms-source")) { if (!map.getSource("plot-wms-source")) {
const LAYER_NAME = "lydc:NYTB"; const LAYER_NAME = "lydc:NYTB4";
map.addSource("plot-wms-source", { map.addSource("plot-wms-source", {
type: "raster", type: "raster",
tiles: [ tiles: [
@@ -690,41 +936,74 @@ function showPlotLayer() {
}); });
} }
// 4. 添加 Raster 图层(如果不存在) // 2. 添加 Layer
if (!map.getLayer("plot-raster-layer")) { if (!map.getLayer("plot-raster-layer")) {
// ✅ 关键修改:添加时,尝试将其放在样地图层下面 (作为 beforeId)
// 如果 SAMPLE_LAYER_ID 不存在beforeId 为 undefined图层会加到最上面
const beforeId = map.getLayer(SAMPLE_LAYER_ID) ? SAMPLE_LAYER_ID : undefined;
map.addLayer({ map.addLayer({
id: "plot-raster-layer", id: "plot-raster-layer",
type: "raster", type: "raster",
source: "plot-wms-source", source: "plot-wms-source",
paint: { paint: { "raster-opacity": 1.0 }
"raster-opacity": 1.0 }, beforeId); // <-- 这里传第二个参数
}
});
} else { } else {
// 5. 如果图层已存在,确保它是可见的
map.setLayoutProperty("plot-raster-layer", "visibility", "visible"); map.setLayoutProperty("plot-raster-layer", "visibility", "visible");
// ✅ 关键修改:如果图层已存在,且样地图层也在,强制把小班图层挪到样地下面
if (map.getLayer(SAMPLE_LAYER_ID)) {
map.moveLayer("plot-raster-layer", SAMPLE_LAYER_ID);
}
} }
map.off("click", handlePlotClick); map.off("click", handlePlotClick);
// 重新绑定点击事件
map.on("click", handlePlotClick); map.on("click", handlePlotClick);
// 改变鼠标样式为小手,提示用户可以点击
map.getCanvas().style.cursor = 'pointer'; map.getCanvas().style.cursor = 'pointer';
console.log("✅ 图斑层已显示"); console.log("✅ 图斑层已显示");
} }
function hideBaseLayers() {
const layersToHide = [ function showSamplePlotLayer() {
"tianjin-fill", "tianjin-outline", "tianjin-label", // 1. 添加 Source (保持不变)
"street-fill", "street-outline" if (!map.getSource(SAMPLE_SOURCE_ID)) {
]; map.addSource(SAMPLE_SOURCE_ID, {
layersToHide.forEach(layerId => { type: "raster",
if (map.getLayer(layerId)) { tiles: [
map.setLayoutProperty(layerId, "visibility", "none"); `/geoserver/lydc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=${SAMPLE_LAYER_NAME}&FORMAT=image/png&TRANSPARENT=true&WIDTH=256&HEIGHT=256&SRS=EPSG:3857&BBOX={bbox-epsg-3857}`
} ],
}); tileSize: 256
});
}
// 2. 添加 Layer
if (!map.getLayer(SAMPLE_LAYER_ID)) {
map.addLayer({
id: SAMPLE_LAYER_ID,
type: "raster",
source: SAMPLE_SOURCE_ID,
paint: { "raster-opacity": 1.0 }
});
// 添加后,如果小班图层存在,样地默认就在它上面(因为是后加的),所以不用特别处理
} else {
map.setLayoutProperty(SAMPLE_LAYER_ID, "visibility", "visible");
// ✅ 关键修改:显式将样地图层挪到最上面 (或者挪到高亮图层下面)
// 简单做法:直接 moveLayer 到顶层
map.moveLayer(SAMPLE_LAYER_ID);
}
// ✅ 额外优化如果这时候有高亮的矢量图层如选中的样地框要把它们重新提到最上面否则会被WMS遮住
if (map.getLayer("samples-fill")) map.moveLayer("samples-fill");
if (map.getLayer("samples-outline")) map.moveLayer("samples-outline");
if (map.getLayer("sideTrees")) map.moveLayer("sideTrees");
console.log("✅ 样地 WMS 图层已加载");
}
function hideSamplePlotLayer() {
if (map.getLayer(SAMPLE_LAYER_ID)) {
map.setLayoutProperty(SAMPLE_LAYER_ID, "visibility", "none");
}
} }
// === 显示样地 === // === 显示样地 ===
// === 显示样地Polygon === // === 显示样地Polygon ===
@@ -950,28 +1229,104 @@ body,
} }
.date-panel { .date-panel {
position: absolute; position: absolute;
top: 80px; /* 距离 Header 下方 20px */ top: 80px;
right: 20px; /* 放在左上角 */ right: 20px;
z-index: 100; /* 保证在地图之上 */ z-index: 100;
background-color: rgba(255, 255, 255, 0.95); background-color: rgba(255, 255, 255, 0.95);
padding: 10px 15px; padding: 10px 15px;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px; /* 元素之间的间距 */
} }
.date-label { /* 按钮通用基础样式 */
.action-btn {
display: flex;
align-items: center;
gap: 4px;
border: none;
border-radius: 4px;
padding: 0 12px;
cursor: pointer;
font-size: 12px;
height: 32px; /* 和 Element Plus 的输入框高度一致 */
color: white;
transition: background 0.3s;
white-space: nowrap;
}
/* 🔵 显示按钮:蓝色 */
.show-btn {
background-color: #409eff; /* Element Blue */
}
.show-btn:hover {
background-color: #66b1ff;
}
/* 🔴 清除按钮:红色 */
.clear-btn {
background-color: #f56c6c; /* Element Red */
}
.clear-btn:hover {
background-color: #ff4d4f;
}
/* 图标微调 */
.action-btn :deep(.el-icon) {
font-size: 14px; font-size: 14px;
font-weight: 600;
color: #333;
} }
/* 调整 Element Plus 输入框的宽度 */ /* 调整 Element Plus 输入框的宽度 */
:deep(.el-date-editor.el-input) { :deep(.el-date-editor.el-input) {
width: 160px; width: 160px;
} }
:deep(.district-hover-popup .maplibregl-popup-content) {
padding: 10px;
background: rgba(255, 255, 255, 0.95);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
font-family: "Microsoft YaHei", sans-serif;
min-width: 150px;
}
:deep(.hover-content) {
display: flex;
flex-direction: column;
gap: 6px;
}
:deep(.hover-title) {
font-size: 14px;
font-weight: bold;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 4px;
margin-bottom: 2px;
}
:deep(.hover-row) {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
}
:deep(.hover-row .value) {
font-weight: 600;
}
/* 给不同数值加点颜色区分 */
:deep(.val-ny) { color: #1976d2; }
:deep(.val-wy) { color: #388e3c; }
:deep(.val-yd) { color: #f57c00; }
:deep(.hover-loading) {
font-size: 12px;
color: #999;
text-align: center;
padding: 5px 0;
}
</style> </style>

View File

@@ -1,148 +1,318 @@
<template> <template>
<div <div
v-if="visible" v-if="visible"
class="plot-popup" class="plot-popup-container"
:style="{ left: position.x + 'px', top: position.y + 'px' }" :style="{ left: position.x + 'px', top: position.y + 'px' }"
> >
<el-card shadow="always" class="popup-card" :body-style="{ padding: '10px', position: 'relative' }"> <div class="popup-wrapper">
<span class="close-btn" @click="$emit('close')">×</span> <el-card shadow="always" class="popup-card" :body-style="{ padding: '0', position: 'relative' }">
<span class="close-btn" @click="$emit('close')">×</span>
<div class="popup-header"> <div class="popup-header-wrapper">
<span class="title">小班号{{ data.nyxbh || '无编号' }}</span> <div class="popup-header">
<el-tag size="small" :type="data.status === '1' ? 'success' : 'info'"> <span class="title">小班号{{ data.plotData?.nyxbh || '无编号' }}</span>
{{ data.status === '1' ? '已采集' : '未完成' }} <el-tag size="small" :type="data.plotData?.status === '1' ? 'success' : 'info'">
</el-tag> {{ data.plotData?.status === '1' ? '已采集' : '未完成' }}
</el-tag>
</div>
</div>
<div class="scroll-content">
<div class="content-padding">
<div class="info-grid">
<div class="info-item" v-for="(label, key) in plotMap" :key="key">
<span class="label">{{ label }}</span>
<span class="value">{{ getPlotDisplay(key) }}</span>
</div>
</div>
<div class="media-container" v-if="mediaItems.length > 0">
<el-carousel height="180px" indicator-position="none" :interval="5000" arrow="hover">
<el-carousel-item v-for="(item, index) in mediaItems" :key="index">
<template v-if="item.type === 'image'">
<el-image :src="item.src" class="media-content" fit="cover" :preview-src-list="allImages"
:initial-index="item.imgIndex" preview-teleported @click.stop />
</template>
<template v-else-if="item.type === 'video'">
<video controls class="media-content">
<source :src="item.src" type="video/mp4" />
您的浏览器不支持视频播放
</video>
</template>
</el-carousel-item>
</el-carousel>
<div class="media-count">{{ mediaItems.length }} 个附件</div>
</div>
<div v-else class="no-media">
暂无现场照片/视频
</div>
<div class="divider" v-if="data.sampleData"></div>
<div class="stats-group" v-if="data.sampleData">
<div class="stats-title">小班下样地统计</div>
<div class="stats-grid">
<div class="stat-box" v-for="(val, key) in data.sampleData" :key="key">
<span class="stat-name">{{ getStatLabel(key) }}</span>
<span class="stat-num">{{ val }}</span>
</div>
</div>
</div>
<div class="popup-actions">
<el-button
type="primary"
size="small"
plain
@click="handleViewSample"
:disabled="!data.sampleData"
:loading="loadingSample"
>
查看样地详情
</el-button>
</div>
</div>
</div>
</el-card>
<div v-if="sampleVisible" class="sample-wrapper">
<SamplePopup
:visible="true"
:data="sampleData"
@close="sampleVisible = false"
/>
</div> </div>
<div class="info-grid"> </div>
<div class="info-item">
<span class="label">位置</span>
<span class="value">{{ data.zlwz || data.cun || '-' }}</span>
</div>
<div class="info-item">
<span class="label">面积</span>
<span class="value">{{ Number(data.xbmj).toFixed(2) }} </span>
</div>
<div class="info-item">
<span class="label">优势树种</span>
<span class="value">{{ data.szzc || '-' }}</span> </div>
<div class="info-item">
<span class="label">林种/地类</span>
<span class="value">{{ data.linZhong || data.diLei || '-' }}</span>
</div>
<div class="info-item">
<span class="label">采集人员</span>
<span class="value">{{ data.collectorName || data.collector || '-' }}</span>
</div>
<div class="info-item">
<span class="label">更新时间</span>
<span class="value">{{ formatDate(data.updateTime || data.createTime) }}</span>
</div>
</div>
<div class="media-container" v-if="mediaItems.length > 0">
<el-carousel
height="180px"
indicator-position="none"
:interval="5000"
arrow="hover"
>
<el-carousel-item
v-for="(item, index) in mediaItems"
:key="index"
>
<template v-if="item.type === 'image'">
<el-image
:src="item.src"
class="media-content"
fit="cover"
:preview-src-list="allImages"
:initial-index="item.imgIndex"
preview-teleported
@click.stop
/>
</template>
<template v-else-if="item.type === 'video'">
<video controls class="media-content">
<source :src="item.src" type="video/mp4" />
您的浏览器不支持视频播放
</video>
</template>
</el-carousel-item>
</el-carousel>
<div class="media-count">{{ mediaItems.length }} 个附件</div>
</div>
<div v-else class="no-media">
暂无现场照片/视频
</div>
<div class="popup-actions">
<el-button type="primary" size="small" plain @click="$emit('enter-sample')">
查看样地详情
</el-button>
<el-button type="success" size="small" plain @click="$emit('enter-side')">
四旁树
</el-button>
</div>
</el-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from "vue"; import { computed, ref } from "vue";
// 不需要引入 ElIcon 或 Close直接用原生 CSS
import { ElCard, ElButton, ElCarousel, ElCarouselItem, ElTag, ElImage } from "element-plus"; import { ElCard, ElButton, ElCarousel, ElCarouselItem, ElTag, ElImage } from "element-plus";
// ✅ 引入样地组件
import SamplePopup from "./SamplePopup.vue";
const props = defineProps({ const props = defineProps({
visible: Boolean, visible: Boolean,
data: { type: Object, default: () => ({}) }, data: { type: Object, default: () => ({ plotData: {}, sampleData: {} }) },
position: { type: Object, default: () => ({ x: 0, y: 0 }) }, position: { type: Object, default: () => ({ x: 0, y: 0 }) },
}); });
defineEmits(['enter-sample', 'enter-side', 'close']); // 因为逻辑内聚了,这里不需要 emit view-sample 了
const emit = defineEmits(['enter-sample', 'enter-side', 'close']);
// 格式化日期,只显示 YYYY-MM-DD const MEDIA_BASE_URL = '/photos'; // 媒体路径前缀
// --- 样地弹窗相关状态 ---
const sampleVisible = ref(false);
const sampleData = ref({});
const loadingSample = ref(false);
// 格式化日期
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
if (!dateStr) return '-'; if (!dateStr) return '-';
return dateStr.split(' ')[0]; return dateStr.split(' ')[0];
}; };
// 整合图片和视频,用于轮播展示 // 媒体数据处理
const mediaItems = computed(() => { const mediaItems = computed(() => {
const imgs = Array.isArray(props.data.images) ? props.data.images : []; const pd = props.data.plotData || {};
const vids = Array.isArray(props.data.videos) ? props.data.videos : []; const imgs = Array.isArray(pd.images) ? pd.images : [];
const vids = Array.isArray(pd.videos) ? pd.videos : [];
let imgCounter = 0; let imgCounter = 0;
return [ return [
...imgs.map((src) => ({ type: "image", src, imgIndex: imgCounter++ })), ...imgs.map((src) => ({ type: "image", src, imgIndex: imgCounter++ })),
...vids.map((src) => ({ type: "video", src })), ...vids.map((src) => ({ type: "video", src })),
]; ];
}); });
// 单独提取所有图片用于大图预览
const allImages = computed(() => { const allImages = computed(() => {
return Array.isArray(props.data.images) ? props.data.images : []; const pd = props.data.plotData || {};
return Array.isArray(pd.images) ? pd.images : [];
}); });
// 字段映射表
const plotMap = {
xian: "县名",
xiang: "乡名",
cun: "村名",
nyxbh: "小班号",
zlwz: "造林位置",
xbmj: "小班面积",
lmsyqs: "林木所有权",
lmqs: "林木权属",
tdsyq: "土地所有权",
diLei: "地类",
linZhong: "林种",
sllb: "森林类别",
szzc: "树种组成",
yssz: "优势树种",
qiYuan: "起源",
yuBiDu: "郁闭度",
pjSg: "平均树高",
pjXj: "平均胸径",
pjnl: "平均年龄",
xbzs: "小班株数",
meiGqXj: "每公顷蓄积",
huoLmXj: "活立木蓄积",
diMao: "地貌",
poDu: "坡度",
poXiang: "坡向",
poWei: "坡位",
tchd: "土层厚度",
gclx: "工程类型",
bcl: "保存率",
bhdj: "公益林保护等级",
bhlydj: "保护利用等级",
sljkdj: "森林健康等级",
slzhdj: "森林灾害等级",
collectorName: "采集人员",
updateTime: "更新时间"
};
function getPlotDisplay(key) {
const pd = props.data.plotData || {};
const root = props.data || {};
let val = pd[key];
if (key === 'zlwz') val = val || root.cun;
if (key === 'xbmj') return val ? Number(val).toFixed(2) + ' 亩' : '-';
if (key === 'linZhong') val = val || pd.diLei;
if (key === 'collectorName') val = val || root.collector;
if (key === 'updateTime') return formatDate(val || pd.createTime);
return (val !== null && val !== undefined && val !== '') ? val : '-';
}
const sampleMap = {
yds: "样地总数",
ysyfs: "幼树样方数",
xmyfs: "下木样方数",
wclyfs: "未成林样方数",
jjlyfs: "经济林样方数",
gmyfs: "灌木样方数",
tbyfs: "藤本样方数",
cbyfs: "草本样方数",
dbyfs: "地被样方数",
mcxms: "目测项目数",
ssms: "散生单木数",
spss: "四旁树单木数"
};
function getStatLabel(key) {
return sampleMap[key] || key;
}
// ✅ 核心逻辑:直接在这里请求样地数据
async function handleViewSample() {
const pd = props.data.plotData || {};
const id = pd.id || pd.ID;
const database = pd.databaseName || pd.database_n || pd.DATABASE_N;
if (!id || !database) {
alert("缺少 ID 或 databaseName无法查询样地");
return;
}
loadingSample.value = true;
try {
const url = `/api/stats/t1sub1?database=${encodeURIComponent(database)}&id=${id}`;
const res = await fetch(url);
const json = await res.json();
if (json.code === 0 && json.data) {
// ✅ 兼容处理:无论后端返回单个对象还是数组,都转成数组
let rawList = [];
if (Array.isArray(json.data)) {
rawList = json.data;
} else {
rawList = [json.data];
}
// 遍历处理每一个样地的媒体路径
const processedList = rawList.map(item => processSampleData(item));
sampleData.value = processedList;
sampleVisible.value = true;
} else {
alert("未查询到样地详情数据");
}
} catch (error) {
console.error("样地请求失败", error);
alert("网络请求失败");
} finally {
loadingSample.value = false;
}
}
// 辅助函数:处理样地媒体路径
function processSampleData(data) {
const images = [];
const videos = [];
if (data.mediaPathList && Array.isArray(data.mediaPathList)) {
data.mediaPathList.forEach(path => {
const fullUrl = `${MEDIA_BASE_URL}${path}`;
const lower = path.toLowerCase();
if (lower.endsWith('.mp4') || lower.endsWith('.avi')) {
videos.push(fullUrl);
} else {
images.push(fullUrl);
}
});
}
return { ...data, images, videos };
}
</script> </script>
<style scoped> <style scoped>
.plot-popup { /* 1. Main Container: Positioned absolutely on the map */
.plot-popup-container {
position: absolute; position: absolute;
z-index: 2000; z-index: 2000;
/* 调整弹窗位置锚点,使其位于点击点的上方居中 */ /* Adjust anchor to be above the click point */
transform: translate(-50%, -100%); transform: translate(-50%, -100%);
margin-top: -15px; /* 留一点间距,别挡住点击点 */ margin-top: -15px;
pointer-events: auto; /* 确保弹窗可交互 */ pointer-events: auto;
/* Allow flex contents to overflow this container if needed */
width: auto;
} }
/* 加上小三角箭头 */ /* 2. Flex Wrapper: Holds Plot Card (left) and Sample Popup (right) */
.plot-popup::after { .popup-wrapper {
display: flex;
align-items: flex-start; /* Align tops */
gap: 10px; /* Space between the two popups */
}
/* 3. The Plot Card itself */
.popup-card {
width: 320px;
border-radius: 8px;
overflow: visible;
flex-shrink: 0; /* Prevent shrinking */
}
/* 4. Sample Popup Wrapper */
.sample-wrapper {
/* No absolute positioning needed here, flex handles it */
z-index: 2001;
}
/* Arrow indicator for the main popup */
.plot-popup-container::after {
content: ''; content: '';
position: absolute; position: absolute;
/* Position relative to the main container */
bottom: -6px; bottom: -6px;
left: 50%; left: 160px; /* Center of the first card (320px / 2) */
transform: translateX(-50%); transform: translateX(-50%);
border-width: 6px 6px 0; border-width: 6px 6px 0;
border-style: solid; border-style: solid;
@@ -150,122 +320,34 @@ const allImages = computed(() => {
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.1)); filter: drop-shadow(0 2px 2px rgba(0,0,0,0.1));
} }
.popup-card { /* ... (Keep all your existing inner styles for close-btn, header, media, etc.) ... */
width: 300px; /* 稍微加宽一点 */
border-radius: 8px;
overflow: visible; /* 允许箭头显示 */
}
/* ✅ 2. 新增:关闭按钮样式 */
.close-btn { .close-btn {
position: absolute; position: absolute; top: 8px; right: 12px; font-size: 24px; font-weight: 300; color: #909399; cursor: pointer; z-index: 100; line-height: 1; transition: color 0.2s; user-select: none;
top: 6px; /* 距离顶部 */
right: 12px; /* 距离右边 */
font-size: 24px; /* 字体大一点,方便点击 */
font-weight: 300; /* 细一点更好看 */
color: #909399;
cursor: pointer;
z-index: 100;
line-height: 1;
transition: color 0.2s;
user-select: none;
} }
.close-btn:hover { color: #f56c6c; }
.close-btn:hover { .popup-header-wrapper { padding: 12px 12px 0 12px; background-color: #fff; border-top-left-radius: 8px; border-top-right-radius: 8px; }
color: #f56c6c; /* 悬停变红 */ .popup-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; border-bottom: 1px solid #eee; padding-right: 25px; }
} .title { font-weight: bold; font-size: 16px; color: #333; }
.scroll-content { max-height: 400px; overflow-y: auto; overflow-x: hidden; }
.popup-header { .content-padding { padding: 10px 12px 12px 12px; }
display: flex; .scroll-content::-webkit-scrollbar { width: 6px; }
justify-content: space-between; .scroll-content::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
align-items: center; .scroll-content::-webkit-scrollbar-track { background: #f5f7fa; }
margin-bottom: 10px; .info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; font-size: 12px; margin-bottom: 12px; }
border-bottom: 1px solid #eee; .info-item { display: flex; overflow: hidden; white-space: nowrap; }
padding-bottom: 8px; .info-item .label { color: #909399; flex-shrink: 0; }
/* ✅ 给标题栏右侧留出空间,防止标题文字太长挡住关闭按钮 */ .info-item .value { color: #303133; font-weight: 500; overflow: hidden; text-overflow: ellipsis; }
padding-right: 25px; .media-container { position: relative; background: #000; border-radius: 4px; overflow: hidden; margin-bottom: 10px; }
} .media-content { width: 100%; height: 100%; object-fit: contain; background-color: #000; display: block; }
.media-count { position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.6); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 10px; }
.title { .no-media { height: 60px; background: #f5f7fa; color: #c0c4cc; display: flex; align-items: center; justify-content: center; font-size: 12px; border-radius: 4px; margin-bottom: 10px; }
font-weight: bold; .divider { height: 1px; background-color: #ebeef5; margin: 15px 0; width: 100%; }
font-size: 16px; .stats-title { font-size: 13px; font-weight: bold; color: #333; margin-bottom: 10px; padding-left: 6px; border-left: 3px solid #409eff; }
color: #333; .stats-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
} .stat-box { background-color: #f9f9f9; border: 1px solid #eee; border-radius: 4px; padding: 8px 4px; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: all 0.2s; }
.stat-box:hover { background-color: #ecf5ff; }
/* 属性网格布局:两列显示 */ .stat-name { font-size: 12px; color: #909399; margin-bottom: 4px; transform: scale(0.9); }
.info-grid { .stat-num { font-size: 14px; font-weight: bold; color: #409eff; }
display: grid; .popup-actions { margin-top: 15px; text-align: right; border-top: 1px dashed #eee; padding-top: 10px; }
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
font-size: 12px;
margin-bottom: 10px;
}
.info-item {
display: flex;
overflow: hidden;
white-space: nowrap;
}
.info-item .label {
color: #909399;
flex-shrink: 0;
}
.info-item .value {
color: #303133;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
/* 媒体区域 */
.media-container {
position: relative;
background: #000;
border-radius: 4px;
overflow: hidden;
}
.media-content {
width: 100%;
height: 100%;
object-fit: contain; /* 保持比例 */
background-color: #000;
display: block;
}
.media-count {
position: absolute;
bottom: 5px;
right: 5px;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
}
.no-media {
height: 60px;
background: #f5f7fa;
color: #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
border-radius: 4px;
}
.popup-actions {
display: flex;
justify-content: space-between;
margin-top: 12px;
padding-top: 8px;
border-top: 1px dashed #eee;
}
.popup-actions .el-button {
flex: 1;
}
</style> </style>

View File

@@ -1,257 +1,263 @@
<template> <template>
<div <div v-if="visible" class="sample-popup-container">
v-if="visible"
class="sample-popup" <div class="popup-wrapper">
:style="{ left: position.x + 'px', top: position.y + 'px' }"
> <el-card shadow="always" class="popup-card" :body-style="{ padding: '0', position: 'relative' }">
<el-card shadow="always" class="popup-card"> <span class="close-btn" @click="$emit('close')">×</span>
<!-- 🟢 样地信息 -->
<template v-if="!selectedTree"> <div class="popup-header-wrapper">
<h3 class="plot-title">{{ data.name }}</h3> <div class="popup-header">
<p>面积{{ data.area }} ha</p> <span class="title">样地详情</span>
<p>采集人员{{ data.collector }}</p> <div class="pagination" v-if="total > 1">
<p>样木数量{{ trees.length }}</p> <el-button size="small" circle :disabled="currentIndex === 0" @click="prevSample" class="nav-btn">&lt;</el-button>
<span class="page-info">{{ currentIndex + 1 }} / {{ total }}</span>
<!-- 图片与视频 --> <el-button size="small" circle :disabled="currentIndex === total - 1" @click="nextSample" class="nav-btn">&gt;</el-button>
<el-carousel </div>
height="220px" <el-tag v-else size="small" type="warning">样地</el-tag>
indicator-position="outside"
:interval="4000"
arrow="always"
v-if="mediaItems.length"
>
<el-carousel-item
v-for="(item, index) in mediaItems"
:key="index"
class="carousel-item"
>
<template v-if="item.type === 'image'">
<img :src="item.src" class="media" />
</template>
<template v-else-if="item.type === 'video'">
<video controls class="media">
<source :src="item.src" type="video/mp4" />
</video>
</template>
</el-carousel-item>
</el-carousel>
<!-- 🌳 样木列表 -->
<h4 class="section-title">样木列表</h4>
<ul class="tree-list">
<li
v-for="(tree, i) in trees"
:key="i"
class="tree-item"
@click="selectTree(tree)"
>
<div class="tree-info">
<span class="tree-id">#{{ tree.id }}</span>
<span class="tree-species">{{ tree.species }}</span>
</div>
<el-icon class="tree-arrow">
<i class="el-icon-arrow-right" />
</el-icon>
</li>
</ul>
</template>
<!-- 🟣 样木详情 -->
<template v-else>
<div class="tree-header">
<el-button
type="primary"
link
size="small"
icon="el-icon-arrow-left"
@click="selectedTree = null"
>
返回样地
</el-button>
<h3>样木 {{ selectedTree.id }}</h3>
</div> </div>
</div>
<p>树种{{ selectedTree.species }}</p>
<p>胸径{{ selectedTree.dbh }} cm</p> <div class="scroll-content">
<p>树高{{ selectedTree.height }} m</p> <div class="content-padding">
<div class="sub-header">ID: {{ currentData.id || '-' }}</div>
<el-carousel
height="220px" <div class="info-grid">
indicator-position="outside" <div class="info-item" v-for="(label, key) in fieldMap" :key="key">
:interval="4000" <span class="label">{{ label }}</span>
arrow="always" <span class="value">{{ getDisplayValue(key) }}</span>
v-if="treeMedia.length" </div>
> </div>
<el-carousel-item
v-for="(item, index) in treeMedia" <div class="media-container" v-if="mediaItems.length > 0">
:key="index" <el-carousel :key="currentIndex" height="160px" indicator-position="none" :interval="4000" arrow="hover">
class="carousel-item" <el-carousel-item v-for="(item, index) in mediaItems" :key="index">
> <template v-if="item.type === 'image'">
<template v-if="item.type === 'image'"> <el-image :src="item.src" class="media-content" fit="cover" :preview-src-list="allImages" :initial-index="item.imgIndex" preview-teleported @click.stop />
<img :src="item.src" class="media" /> </template>
</template> <template v-else-if="item.type === 'video'">
<template v-else-if="item.type === 'video'"> <video controls class="media-content"><source :src="item.src" type="video/mp4" /></video>
<video controls class="media"> </template>
<source :src="item.src" type="video/mp4" /> </el-carousel-item>
</video> </el-carousel>
</template> <div class="media-count">{{ mediaItems.length }} 个附件</div>
</el-carousel-item> </div>
</el-carousel> <div v-else class="no-media">暂无样地影像</div>
</template>
<div class="popup-actions">
<el-button type="success" size="small" plain @click="handleViewTree" :loading="loadingTree">
查看样木信息
</el-button>
</div>
</div>
</div>
</el-card> </el-card>
<div v-if="treeVisible" class="tree-wrapper">
<TreePopup
:visible="true"
:data="treeData"
@close="treeVisible = false"
/>
</div>
</div> </div>
</template> </div>
</template>
<script setup>
import { ref, computed } from "vue"; <script setup>
import { import { computed, ref, watch } from "vue";
ElCard, import { ElCard, ElCarousel, ElCarouselItem, ElTag, ElImage, ElButton } from "element-plus";
ElButton, import TreePopup from "./TreePopup.vue";
ElCarousel,
ElCarouselItem,
ElIcon,
} from "element-plus";
import "element-plus/es/components/card/style/css";
import "element-plus/es/components/button/style/css";
import "element-plus/es/components/carousel/style/css";
import "element-plus/es/components/carousel-item/style/css";
import "element-plus/es/components/icon/style/css";
const props = defineProps({ const props = defineProps({
visible: Boolean, visible: Boolean,
data: { type: Object, default: () => ({}) }, data: { type: [Array, Object], default: () => [] },
position: { type: Object, default: () => ({ x: 0, y: 0 }) }, // position 不需要了,因为完全由 CSS 布局控制
}); });
const selectedTree = ref(null); defineEmits(['close']);
// 样地内样木列表 // 样地相关逻辑 (保持不变)
const trees = computed(() => props.data.trees || []); const currentIndex = ref(0);
watch(() => props.visible, (val) => { if (val) currentIndex.value = 0; });
// 样地媒体 const dataList = computed(() => {
if (Array.isArray(props.data)) return props.data;
if (props.data && Object.keys(props.data).length > 0) return [props.data];
return [];
});
const total = computed(() => dataList.value.length);
const currentData = computed(() => dataList.value[currentIndex.value] || {});
const prevSample = () => { if (currentIndex.value > 0) currentIndex.value--; };
const nextSample = () => { if (currentIndex.value < total.value - 1) currentIndex.value++; };
const fieldMap = { parentId: "所属小班ID", databaseName: "数据库名", pjXj: "平均胸径(cm)", zs: "株数", ydzxj: "样地总蓄积", angle: "角度", area: "面积", length: "长度", width: "宽度", version: "版本号", bz: "备注" };
function getDisplayValue(key) {
const val = currentData.value[key];
if (val === null || val === undefined || val === '') return '-';
if (key === 'pjXj' || key === 'ydzxj') return Number(val).toFixed(2);
return val;
}
const mediaItems = computed(() => { const mediaItems = computed(() => {
const imgs = Array.isArray(props.data.images) ? props.data.images : []; const cur = currentData.value;
const vids = Array.isArray(props.data.videos) ? props.data.videos : []; const imgs = Array.isArray(cur.images) ? cur.images : [];
return [ const vids = Array.isArray(cur.videos) ? cur.videos : [];
...imgs.map((src) => ({ type: "image", src })), let imgCounter = 0;
...vids.map((src) => ({ type: "video", src })), return [...imgs.map((src) => ({ type: "image", src, imgIndex: imgCounter++ })), ...vids.map((src) => ({ type: "video", src }))];
];
}); });
const allImages = computed(() => { const cur = currentData.value; return Array.isArray(cur.images) ? cur.images : []; });
// 当前选中样木媒体 // --- 样木相关逻辑 ---
const treeMedia = computed(() => { const treeVisible = ref(false);
if (!selectedTree.value) return []; const treeData = ref([]);
const imgs = selectedTree.value.images || []; const loadingTree = ref(false);
const vids = selectedTree.value.videos || [];
return [
...imgs.map((src) => ({ type: "image", src })),
...vids.map((src) => ({ type: "video", src })),
];
});
function selectTree(tree) { async function handleViewTree() {
selectedTree.value = tree; const cur = currentData.value;
const id = cur.id;
const database = cur.databaseName;
if (!id || !database) {
alert("缺少样地ID或数据库名");
return;
}
loadingTree.value = true;
try {
const url = `/api/stats/t1sub2?database=${encodeURIComponent(database)}&id=${id}`;
const res = await fetch(url);
const json = await res.json();
if (json.code === 0 && json.data) {
// 兼容单条/多条
if (Array.isArray(json.data)) {
treeData.value = json.data;
} else {
treeData.value = [json.data];
}
treeVisible.value = true;
} else {
alert("未查询到样木数据");
}
} catch (error) {
console.error("样木查询失败", error);
alert("请求失败");
} finally {
loadingTree.value = false;
}
} }
</script> </script>
<style scoped> <style scoped>
/* 1. 外层容器:相对定位 (相对于父组件 PlotPopup 的 flex item) */
.sample-popup { .sample-popup {
position: relative;
z-index: 2001;
width: 280px;
}
/* 箭头 (指向左边的小班弹窗) */
.sample-popup::after {
content: '';
position: absolute; position: absolute;
z-index: 999; bottom: 20px;
transform: translate(-50%, -100%); left: -6px; /* 指向左侧 */
border-width: 6px 6px 6px 0; /* 向右的三角形 */
border-style: solid;
border-color: transparent #fff transparent transparent;
filter: drop-shadow(-2px 0 2px rgba(0,0,0,0.1));
} }
/* 2. Flex 布局:让样地卡片和样木弹窗并排 */
.popup-wrapper {
display: flex;
align-items: flex-start;
gap: 10px; /* 间距 */
}
/* 3. 样地卡片样式 */
.popup-card { .popup-card {
width: 300px; width: 280px;
font-size: 13px;
border-radius: 10px;
overflow: hidden;
}
.plot-title {
font-size: 16px;
font-weight: bold;
color: #2e7d32;
}
.section-title {
margin-top: 10px;
font-size: 14px;
font-weight: 600;
color: #1976d2;
}
/* === 样木列表 === */
.tree-list {
list-style: none;
padding: 0;
margin: 5px 0;
max-height: 130px;
overflow-y: auto;
}
.tree-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #e3f2fd;
padding: 8px 10px;
margin-bottom: 6px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.tree-item:hover {
background: #bbdefb;
transform: translateY(-1px);
}
.tree-info {
display: flex;
flex-direction: column;
}
.tree-id {
font-weight: bold;
color: #1565c0;
}
.tree-species {
font-size: 12px;
color: #444;
}
.tree-arrow {
color: #1565c0;
}
/* 滚动条优化 */
.tree-list::-webkit-scrollbar {
width: 6px;
}
.tree-list::-webkit-scrollbar-thumb {
background-color: #90caf9;
border-radius: 3px;
}
.tree-list::-webkit-scrollbar-thumb:hover {
background-color: #64b5f6;
}
/* 图片与视频 */
.media {
width: 100%;
height: 220px;
object-fit: cover;
border-radius: 8px; border-radius: 8px;
overflow: visible;
border: 1px solid #dcdfe6;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
flex-shrink: 0; /* 防止被挤压 */
} }
/* 样木详情页头部 */ /* 4. 样木弹窗包装器 */
.tree-header { .tree-wrapper {
display: flex; z-index: 2002;
align-items: center;
justify-content: space-between;
} }
</style>
/* 关闭按钮 */
.close-btn {
position: absolute;
top: 8px;
right: 8px; /* 稍微靠右一点 */
font-size: 24px;
font-weight: 300;
color: #fff;
cursor: pointer;
z-index: 100; /* 确保在最上层 */
line-height: 1;
}
.close-btn:hover { color: #ffe6e6; }
.popup-header-wrapper {
background-color: #5db862; /* 绿色主题 */
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
/* ✅ 关键修复:右侧预留空间,防止被关闭按钮遮挡 */
padding-right: 35px;
color: white;
height: 32px;
}
.title { font-weight: bold; font-size: 14px; white-space: nowrap; }
/* 翻页器样式 */
.pagination {
display: flex; align-items: center; gap: 4px;
}
.page-info { font-size: 12px; font-weight: bold; white-space: nowrap; }
.nav-btn {
width: 18px; height: 18px; min-height: 18px; font-size: 12px; padding: 0;
background: rgba(255,255,255,0.2); border: none; color: white;
}
.nav-btn:hover:not(:disabled) { background: rgba(255,255,255,0.4); }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.scroll-content {
max-height: 350px; overflow-y: auto; overflow-x: hidden;
background: #fff; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;
}
.content-padding { padding: 12px; }
.sub-header { font-size: 12px; color: #909399; margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 4px; }
.info-grid { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.info-item { display: flex; justify-content: space-between; border-bottom: 1px dashed #eee; padding-bottom: 4px; font-size: 12px; }
.info-item .label { color: #909399; flex-shrink: 0; }
.info-item .value { color: #303133; font-weight: bold; word-break: break-all; text-align: right; }
.media-container { position: relative; background: #000; border-radius: 4px; overflow: hidden; }
.media-content { width: 100%; height: 100%; object-fit: contain; background-color: #000; display: block; }
.media-count { position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.6); color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 10px; }
.no-media { height: 50px; background: #f5f7fa; color: #c0c4cc; display: flex; align-items: center; justify-content: center; font-size: 12px; border-radius: 4px; }
.popup-actions { margin-top: 15px; text-align: right; border-top: 1px dashed #eee; padding-top: 10px; }
/* 滚动条 */
.scroll-content::-webkit-scrollbar { width: 6px; }
.scroll-content::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
.scroll-content::-webkit-scrollbar-track { background: #f5f7fa; }
</style>

View File

@@ -0,0 +1,276 @@
<template>
<div v-if="visible" class="tree-popup" :style="{ left: position.x + 'px', top: position.y + 'px' }">
<el-card shadow="always" class="popup-card" :body-style="{ padding: '0', position: 'relative' }">
<span class="close-btn" @click="$emit('close')">×</span>
<div class="popup-header-wrapper">
<div class="popup-header">
<span class="title">样木详情</span>
<div class="pagination" v-if="total > 1">
<el-button size="small" circle :disabled="currentIndex === 0" @click="prevItem" class="nav-btn">&lt;</el-button>
<span class="page-info">{{ currentIndex + 1 }} / {{ total }}</span>
<el-button size="small" circle :disabled="currentIndex === total - 1" @click="nextItem" class="nav-btn">&gt;</el-button>
</div>
<el-tag v-else size="small" type="success">样木</el-tag>
</div>
</div>
<div class="scroll-content">
<div class="content-padding">
<div class="sub-header">
ID: {{ currentData.id || '-' }}
</div>
<div class="info-grid">
<div class="info-item" v-for="(label, key) in fieldMap" :key="key">
<span class="label">{{ label }}</span>
<span class="value">{{ getDisplayValue(key) }}</span>
</div>
</div>
<div class="media-container" v-if="mediaItems.length > 0">
<el-carousel :key="currentIndex" height="160px" indicator-position="none" :interval="4000" arrow="hover">
<el-carousel-item v-for="(item, index) in mediaItems" :key="index">
<el-image
:src="item.src"
class="media-content"
fit="contain"
:preview-src-list="allImages"
:initial-index="item.imgIndex"
preview-teleported
@click.stop
/>
</el-carousel-item>
</el-carousel>
</div>
<div v-else class="no-media">暂无样木照片</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElCard, ElCarousel, ElCarouselItem, ElTag, ElImage, ElButton } from "element-plus";
const props = defineProps({
visible: Boolean,
data: { type: [Array, Object], default: () => [] },
position: { type: Object, default: () => ({ x: 0, y: 0 }) }
});
defineEmits(['close']);
const MEDIA_BASE_URL = '/photos'; // 确保和之前一致
const currentIndex = ref(0);
watch(() => props.visible, (val) => {
if (val) currentIndex.value = 0;
});
// 数据标准化:转数组
const dataList = computed(() => {
if (Array.isArray(props.data)) return props.data;
if (props.data && Object.keys(props.data).length > 0) return [props.data];
return [];
});
const total = computed(() => dataList.value.length);
const currentData = computed(() => dataList.value[currentIndex.value] || {});
const prevItem = () => { if (currentIndex.value > 0) currentIndex.value--; };
const nextItem = () => { if (currentIndex.value < total.value - 1) currentIndex.value++; };
// ✅ 样木字段映射
const fieldMap = {
sz: "树种",
xj: "胸径(cm)",
cjl: "材积率", // 或 蓄积
ymh: "样木号",
parentId: "所属样地ID",
grandparentid: "所属小班ID",
databaseName: "数据库",
version: "版本",
type: "类型"
};
function getDisplayValue(key) {
const val = currentData.value[key];
if (val === null || val === undefined || val === '') return '-';
if (key === 'xj' || key === 'cjl') return Number(val).toFixed(2);
return val;
}
// ✅ 媒体处理:样木接口返回的是 photo 字符串,不是 list
const mediaItems = computed(() => {
const photoPath = currentData.value.photo;
if (!photoPath) return [];
// 构造完整的图片路径
const fullSrc = `${MEDIA_BASE_URL}${photoPath}`;
return [{ type: "image", src: fullSrc, imgIndex: 0 }];
});
const allImages = computed(() => mediaItems.value.map(i => i.src));
</script>
<style scoped>
.tree-popup {
position: relative; /* 配合 Flex 布局 */
z-index: 2002;
width: 260px;
}
/* 箭头指向左侧 */
.tree-popup::after {
content: '';
position: absolute;
bottom: 20px;
left: -6px;
border-width: 6px 6px 6px 0;
border-style: solid;
border-color: transparent #fff transparent transparent;
filter: drop-shadow(-2px 0 2px rgba(0,0,0,0.1));
}
.popup-card {
border-radius: 8px;
border: 1px solid #dcdfe6;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
/* 关闭按钮 */
.close-btn {
position: absolute;
top: 8px;
right: 8px; /* 稍微靠右一点 */
font-size: 20px;
font-weight: bold;
color: #fff;
cursor: pointer;
z-index: 10; /* 确保在 header 内容之上 */
line-height: 1;
opacity: 0.8;
}
.close-btn:hover {
opacity: 1;
color: #ffe6e6;
}
.popup-header-wrapper {
background-color: #67c23a; /* 绿色头部 */
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
/* ✅ 关键修改:右边距避让关闭按钮 */
padding-right: 35px;
color: white;
height: 32px;
}
.title {
font-weight: bold;
font-size: 14px;
white-space: nowrap; /* 防止标题换行 */
}
/* 翻页器 */
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.page-info {
font-size: 12px;
font-weight: bold;
white-space: nowrap;
}
.nav-btn {
width: 18px;
height: 18px;
min-height: 18px;
font-size: 12px;
padding: 0;
background: rgba(255,255,255,0.2);
border: none;
color: white;
}
.nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 内容区域 */
.scroll-content {
max-height: 350px;
overflow-y: auto;
background: #fff;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.content-padding {
padding: 12px;
}
.sub-header {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
border-bottom: 1px solid #eee;
padding-bottom: 4px;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
border-bottom: 1px dashed #eee;
padding-bottom: 4px;
font-size: 12px;
}
.info-item .label { color: #909399; flex-shrink: 0; }
.info-item .value { color: #303133; font-weight: bold; text-align: right; word-break: break-all; }
.media-container {
position: relative;
background: #000;
border-radius: 4px;
overflow: hidden;
}
.media-content {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.no-media {
height: 50px;
background: #f5f7fa;
color: #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
border-radius: 4px;
}
/* 滚动条美化 */
.scroll-content::-webkit-scrollbar { width: 6px; }
.scroll-content::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
</style>