更新样地和样木弹窗逻辑
This commit is contained in:
@@ -36,21 +36,16 @@ export const fetchPlotGeoJSON = async () => {
|
||||
return {
|
||||
...feature,
|
||||
properties: {
|
||||
// 保留原始属性
|
||||
...p,
|
||||
|
||||
// --- 关键映射区域 (根据你的数据库字段修改左边的值) ---
|
||||
name: p.xb_name || p.name || "未命名图斑",
|
||||
|
||||
// 这里的映射决定了地图颜色!确保数据库里有 "乔木林"/"灌木林" 等值
|
||||
// 如果数据库存的是代码(1,2,3),这里需要写转换逻辑
|
||||
|
||||
type: p.forest_type || p.type || "其他",
|
||||
|
||||
area: p.area_mu || 0,
|
||||
collector: p.manager || "未知人员",
|
||||
|
||||
// --- 图片/视频处理 ---
|
||||
// 假设数据库里存的是 "url1,url2" 这样的字符串
|
||||
images: p.image_urls ? p.image_urls.split(',') : [],
|
||||
videos: p.video_urls ? p.video_urls.split(',') : [],
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<div id="map" ref="mapContainer" class="map-container"></div>
|
||||
<div id="dateSelectItem" class="date-panel">
|
||||
<span class="date-label">数据日期:</span>
|
||||
<span class="date-label">选择日期:</span>
|
||||
<el-date-picker
|
||||
v-model="selectedDate"
|
||||
type="date"
|
||||
@@ -16,6 +16,23 @@
|
||||
@change="handleDateChange"
|
||||
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 class="legend-panel">
|
||||
@@ -23,7 +40,7 @@
|
||||
<div class="control-item group-item">
|
||||
<label class="group-header">
|
||||
<input type="checkbox" v-model="isStatLayerVisible" />
|
||||
<span>统计数据 ({{ currentLevel === 'district' ? '区级' : '街道' }})</span>
|
||||
<span>统计数据 </span>
|
||||
</label>
|
||||
|
||||
<div v-if="isStatLayerVisible" class="group-body">
|
||||
@@ -36,13 +53,13 @@
|
||||
<div class="control-item">
|
||||
<label>
|
||||
<input type="checkbox" v-model="isPlotLayerVisible" />
|
||||
所有图斑
|
||||
小班图层
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-item">
|
||||
<label>
|
||||
<input type="checkbox" />
|
||||
样地图斑
|
||||
<input type="checkbox" v-model="isSampleLayerVisible" />
|
||||
样地图层
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +77,6 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
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 SamplePopup from "./SamplePopup.vue";
|
||||
import bbox from "@turf/bbox";
|
||||
import { Close, Search } from "@element-plus/icons-vue";
|
||||
|
||||
|
||||
const popupVisible = ref(false);
|
||||
const popupData = ref({});
|
||||
const popupPos = ref({ x: 0, y: 0 });
|
||||
const isPlotLayerVisible = ref(false);
|
||||
const isStatLayerVisible = ref(true);
|
||||
const isSampleLayerVisible = ref(false);
|
||||
|
||||
// 天津市行政区划数据(区级)
|
||||
const districtUrl ="https://geo.datav.aliyun.com/areas_v3/bound/120000_full.json";
|
||||
@@ -96,6 +115,14 @@ const mapContainer = ref(null);
|
||||
let map = null;
|
||||
const legendItems = ref([]);
|
||||
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, () => {
|
||||
updateStatLayerState();
|
||||
});
|
||||
@@ -107,17 +134,112 @@ watch(isPlotLayerVisible, (newVal) => {
|
||||
hidePlotLayer();
|
||||
}
|
||||
});
|
||||
watch(isSampleLayerVisible, (newVal) => {
|
||||
if (newVal) {
|
||||
showSamplePlotLayer();
|
||||
} else {
|
||||
hideSamplePlotLayer();
|
||||
}
|
||||
});
|
||||
const selectedDate = ref(new Date().toISOString().split('T')[0]); // 默认今天
|
||||
|
||||
// ✅ 新增:日期变更处理函数
|
||||
function handleDateChange(val) {
|
||||
function calculateDistrictCenters(geojson) {
|
||||
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);
|
||||
|
||||
// 在这里添加你的逻辑,例如:
|
||||
// 1. 重新请求统计接口
|
||||
// fetchDistrictStatus(val);
|
||||
// 2. 刷新 WMS 图层 (如果 WMS 支持 TIME 参数)
|
||||
// refreshWmsLayer(val);
|
||||
// 1. 先清除旧的标注
|
||||
clearDateMarkers();
|
||||
|
||||
// 2. 请求新数据
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
完成状态:{},
|
||||
@@ -293,6 +431,19 @@ function processPlotData(data) {
|
||||
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) {
|
||||
const districtName = e.features[0].properties.name;
|
||||
@@ -364,6 +515,7 @@ onMounted(async () => {
|
||||
|
||||
const res = await fetch(districtUrl);
|
||||
const geojson = await res.json();
|
||||
calculateDistrictCenters(geojson);
|
||||
|
||||
map.on("load", () => {
|
||||
map.addSource("tianjin", { type: "geojson", data: geojson });
|
||||
@@ -401,13 +553,101 @@ onMounted(async () => {
|
||||
"text-halo-width": 2 // 描边宽度
|
||||
}
|
||||
});
|
||||
|
||||
updateMapColors();
|
||||
|
||||
// 点击区多边形,下钻街道级
|
||||
map.off("click", "tianjin-fill", handleDistrictClick);
|
||||
map.on("click", "tianjin-fill", handleDistrictClick);
|
||||
// map.off("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(",");
|
||||
|
||||
const baseUrl = "/geoserver/lydc/wms";
|
||||
const layerName = "lydc:NYTB";
|
||||
const layerName = "lydc:NYTB4";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
SERVICE: "WMS",
|
||||
@@ -633,6 +873,7 @@ async function handlePlotClick(e) {
|
||||
if (!wmsRes.ok) throw new Error(`HTTP ${wmsRes.status}`);
|
||||
|
||||
const wmsData = await wmsRes.json();
|
||||
console.log("WMS GetFeatureInfo 返回:", wmsData);
|
||||
|
||||
if (wmsData.features && wmsData.features.length > 0) {
|
||||
const wmsProp = wmsData.features[0].properties;
|
||||
@@ -644,14 +885,19 @@ async function handlePlotClick(e) {
|
||||
const cunCode = String(wmsProp.cun || wmsProp.CUN);
|
||||
xiang = cunCode.substring(0, 9);
|
||||
}
|
||||
const rootId = wmsProp.id || wmsProp.ID;
|
||||
const databaseName = wmsProp.database_n || wmsProp.DATABASE_N;
|
||||
|
||||
if (nyxbh && xiang) {
|
||||
const detailData = await fetchPlotData(nyxbh, xiang);
|
||||
if (detailData) {
|
||||
if (nyxbh && xiang&&rootId&&databaseName) {
|
||||
const [plotData, sampleData] = await Promise.all([
|
||||
fetchPlotData(nyxbh, xiang),
|
||||
fetchPlotStats(rootId, databaseName)
|
||||
]);
|
||||
if (plotData&&sampleData) {
|
||||
const canvasRect = map.getCanvas().getBoundingClientRect();
|
||||
const pos = map.project(e.lngLat);
|
||||
popupPos.value = { x: pos.x + canvasRect.left, y: pos.y + canvasRect.top };
|
||||
popupData.value = detailData;
|
||||
popupData.value = {plotData, sampleData };
|
||||
popupVisible.value = true;
|
||||
} else {
|
||||
alert("未查询到详细数据");
|
||||
@@ -678,9 +924,9 @@ function handleMapClickClosePopup(e) {
|
||||
let cachedPlotData = null;
|
||||
|
||||
function showPlotLayer() {
|
||||
// 3. 如果 Source 不存在,添加 WMS 源
|
||||
// 1. 添加 Source (保持不变)
|
||||
if (!map.getSource("plot-wms-source")) {
|
||||
const LAYER_NAME = "lydc:NYTB";
|
||||
const LAYER_NAME = "lydc:NYTB4";
|
||||
map.addSource("plot-wms-source", {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
@@ -690,41 +936,74 @@ function showPlotLayer() {
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 添加 Raster 图层(如果不存在)
|
||||
// 2. 添加 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({
|
||||
id: "plot-raster-layer",
|
||||
type: "raster",
|
||||
source: "plot-wms-source",
|
||||
paint: {
|
||||
"raster-opacity": 1.0
|
||||
}
|
||||
});
|
||||
paint: { "raster-opacity": 1.0 }
|
||||
}, beforeId); // <-- 这里传第二个参数
|
||||
} else {
|
||||
// 5. 如果图层已存在,确保它是可见的
|
||||
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.on("click", handlePlotClick);
|
||||
|
||||
// 改变鼠标样式为小手,提示用户可以点击
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
|
||||
console.log("✅ 图斑层已显示");
|
||||
}
|
||||
function hideBaseLayers() {
|
||||
const layersToHide = [
|
||||
"tianjin-fill", "tianjin-outline", "tianjin-label",
|
||||
"street-fill", "street-outline"
|
||||
];
|
||||
layersToHide.forEach(layerId => {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, "visibility", "none");
|
||||
}
|
||||
});
|
||||
|
||||
function showSamplePlotLayer() {
|
||||
// 1. 添加 Source (保持不变)
|
||||
if (!map.getSource(SAMPLE_SOURCE_ID)) {
|
||||
map.addSource(SAMPLE_SOURCE_ID, {
|
||||
type: "raster",
|
||||
tiles: [
|
||||
`/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) ===
|
||||
@@ -950,28 +1229,104 @@ body,
|
||||
}
|
||||
.date-panel {
|
||||
position: absolute;
|
||||
top: 80px; /* 距离 Header 下方 20px */
|
||||
right: 20px; /* 放在左上角 */
|
||||
z-index: 100; /* 保证在地图之上 */
|
||||
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
|
||||
display: flex;
|
||||
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-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 调整 Element Plus 输入框的宽度 */
|
||||
:deep(.el-date-editor.el-input) {
|
||||
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>
|
||||
|
||||
@@ -1,148 +1,318 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="plot-popup"
|
||||
<div
|
||||
v-if="visible"
|
||||
class="plot-popup-container"
|
||||
: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">
|
||||
<span class="title">小班号:{{ data.nyxbh || '无编号' }}</span>
|
||||
<el-tag size="small" :type="data.status === '1' ? 'success' : 'info'">
|
||||
{{ data.status === '1' ? '已采集' : '未完成' }}
|
||||
</el-tag>
|
||||
<div class="popup-header-wrapper">
|
||||
<div class="popup-header">
|
||||
<span class="title">小班号:{{ data.plotData?.nyxbh || '无编号' }}</span>
|
||||
<el-tag size="small" :type="data.plotData?.status === '1' ? 'success' : 'info'">
|
||||
{{ 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 class="info-grid">
|
||||
<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>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
// 不需要引入 ElIcon 或 Close,直接用原生 CSS
|
||||
import { computed, ref } from "vue";
|
||||
import { ElCard, ElButton, ElCarousel, ElCarouselItem, ElTag, ElImage } from "element-plus";
|
||||
// ✅ 引入样地组件
|
||||
import SamplePopup from "./SamplePopup.vue";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
data: { type: Object, default: () => ({}) },
|
||||
data: { type: Object, default: () => ({ plotData: {}, sampleData: {} }) },
|
||||
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) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split(' ')[0];
|
||||
};
|
||||
|
||||
// 整合图片和视频,用于轮播展示
|
||||
// 媒体数据处理
|
||||
const mediaItems = computed(() => {
|
||||
const imgs = Array.isArray(props.data.images) ? props.data.images : [];
|
||||
const vids = Array.isArray(props.data.videos) ? props.data.videos : [];
|
||||
|
||||
const pd = props.data.plotData || {};
|
||||
const imgs = Array.isArray(pd.images) ? pd.images : [];
|
||||
const vids = Array.isArray(pd.videos) ? pd.videos : [];
|
||||
let imgCounter = 0;
|
||||
|
||||
return [
|
||||
...imgs.map((src) => ({ type: "image", src, imgIndex: imgCounter++ })),
|
||||
...vids.map((src) => ({ type: "video", src })),
|
||||
];
|
||||
});
|
||||
|
||||
// 单独提取所有图片用于大图预览
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.plot-popup {
|
||||
/* 1. Main Container: Positioned absolutely on the map */
|
||||
.plot-popup-container {
|
||||
position: absolute;
|
||||
z-index: 2000;
|
||||
/* 调整弹窗位置锚点,使其位于点击点的上方居中 */
|
||||
/* Adjust anchor to be above the click point */
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -15px; /* 留一点间距,别挡住点击点 */
|
||||
pointer-events: auto; /* 确保弹窗可交互 */
|
||||
margin-top: -15px;
|
||||
pointer-events: auto;
|
||||
/* Allow flex contents to overflow this container if needed */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 加上小三角箭头 */
|
||||
.plot-popup::after {
|
||||
/* 2. Flex Wrapper: Holds Plot Card (left) and Sample Popup (right) */
|
||||
.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: '';
|
||||
position: absolute;
|
||||
/* Position relative to the main container */
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
left: 160px; /* Center of the first card (320px / 2) */
|
||||
transform: translateX(-50%);
|
||||
border-width: 6px 6px 0;
|
||||
border-style: solid;
|
||||
@@ -150,122 +320,34 @@ const allImages = computed(() => {
|
||||
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.popup-card {
|
||||
width: 300px; /* 稍微加宽一点 */
|
||||
border-radius: 8px;
|
||||
overflow: visible; /* 允许箭头显示 */
|
||||
}
|
||||
|
||||
/* ✅ 2. 新增:关闭按钮样式 */
|
||||
/* ... (Keep all your existing inner styles for close-btn, header, media, etc.) ... */
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.close-btn:hover { color: #f56c6c; }
|
||||
|
||||
.close-btn:hover {
|
||||
color: #f56c6c; /* 悬停变红 */
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 8px;
|
||||
/* ✅ 给标题栏右侧留出空间,防止标题文字太长挡住关闭按钮 */
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 属性网格布局:两列显示 */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
.popup-header-wrapper { padding: 12px 12px 0 12px; background-color: #fff; border-top-left-radius: 8px; border-top-right-radius: 8px; }
|
||||
.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; }
|
||||
.content-padding { padding: 10px 12px 12px 12px; }
|
||||
.scroll-content::-webkit-scrollbar { width: 6px; }
|
||||
.scroll-content::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
|
||||
.scroll-content::-webkit-scrollbar-track { background: #f5f7fa; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; font-size: 12px; margin-bottom: 12px; }
|
||||
.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; 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; }
|
||||
.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; }
|
||||
.divider { height: 1px; background-color: #ebeef5; margin: 15px 0; width: 100%; }
|
||||
.stats-title { font-size: 13px; font-weight: bold; color: #333; margin-bottom: 10px; padding-left: 6px; border-left: 3px solid #409eff; }
|
||||
.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); }
|
||||
.stat-num { font-size: 14px; font-weight: bold; color: #409eff; }
|
||||
.popup-actions { margin-top: 15px; text-align: right; border-top: 1px dashed #eee; padding-top: 10px; }
|
||||
</style>
|
||||
@@ -1,257 +1,263 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="sample-popup"
|
||||
:style="{ left: position.x + 'px', top: position.y + 'px' }"
|
||||
>
|
||||
<el-card shadow="always" class="popup-card">
|
||||
<!-- 🟢 样地信息 -->
|
||||
<template v-if="!selectedTree">
|
||||
<h3 class="plot-title">{{ data.name }}</h3>
|
||||
<p>面积:{{ data.area }} ha</p>
|
||||
<p>采集人员:{{ data.collector }}</p>
|
||||
<p>样木数量:{{ trees.length }}</p>
|
||||
|
||||
<!-- 图片与视频 -->
|
||||
<el-carousel
|
||||
height="220px"
|
||||
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 v-if="visible" class="sample-popup-container">
|
||||
|
||||
<div class="popup-wrapper">
|
||||
|
||||
<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="prevSample" class="nav-btn"><</el-button>
|
||||
<span class="page-info">{{ currentIndex + 1 }} / {{ total }}</span>
|
||||
<el-button size="small" circle :disabled="currentIndex === total - 1" @click="nextSample" class="nav-btn">></el-button>
|
||||
</div>
|
||||
<el-tag v-else size="small" type="warning">样地</el-tag>
|
||||
</div>
|
||||
|
||||
<p>树种:{{ selectedTree.species }}</p>
|
||||
<p>胸径:{{ selectedTree.dbh }} cm</p>
|
||||
<p>树高:{{ selectedTree.height }} m</p>
|
||||
|
||||
<el-carousel
|
||||
height="220px"
|
||||
indicator-position="outside"
|
||||
:interval="4000"
|
||||
arrow="always"
|
||||
v-if="treeMedia.length"
|
||||
>
|
||||
<el-carousel-item
|
||||
v-for="(item, index) in treeMedia"
|
||||
: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>
|
||||
</template>
|
||||
</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">
|
||||
<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="success" size="small" plain @click="handleViewTree" :loading="loadingTree">
|
||||
查看样木信息
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="treeVisible" class="tree-wrapper">
|
||||
<TreePopup
|
||||
:visible="true"
|
||||
:data="treeData"
|
||||
@close="treeVisible = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import {
|
||||
ElCard,
|
||||
ElButton,
|
||||
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";
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ElCard, ElCarousel, ElCarouselItem, ElTag, ElImage, ElButton } from "element-plus";
|
||||
import TreePopup from "./TreePopup.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
data: { type: Object, default: () => ({}) },
|
||||
position: { type: Object, default: () => ({ x: 0, y: 0 }) },
|
||||
data: { type: [Array, Object], default: () => [] },
|
||||
// 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 imgs = Array.isArray(props.data.images) ? props.data.images : [];
|
||||
const vids = Array.isArray(props.data.videos) ? props.data.videos : [];
|
||||
return [
|
||||
...imgs.map((src) => ({ type: "image", src })),
|
||||
...vids.map((src) => ({ type: "video", src })),
|
||||
];
|
||||
const cur = currentData.value;
|
||||
const imgs = Array.isArray(cur.images) ? cur.images : [];
|
||||
const vids = Array.isArray(cur.videos) ? cur.videos : [];
|
||||
let imgCounter = 0;
|
||||
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(() => {
|
||||
if (!selectedTree.value) return [];
|
||||
const imgs = selectedTree.value.images || [];
|
||||
const vids = selectedTree.value.videos || [];
|
||||
return [
|
||||
...imgs.map((src) => ({ type: "image", src })),
|
||||
...vids.map((src) => ({ type: "video", src })),
|
||||
];
|
||||
});
|
||||
// --- 样木相关逻辑 ---
|
||||
const treeVisible = ref(false);
|
||||
const treeData = ref([]);
|
||||
const loadingTree = ref(false);
|
||||
|
||||
function selectTree(tree) {
|
||||
selectedTree.value = tree;
|
||||
async function handleViewTree() {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
<style scoped>
|
||||
/* 1. 外层容器:相对定位 (相对于父组件 PlotPopup 的 flex item) */
|
||||
.sample-popup {
|
||||
position: relative;
|
||||
z-index: 2001;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
/* 箭头 (指向左边的小班弹窗) */
|
||||
.sample-popup::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
transform: translate(-50%, -100%);
|
||||
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));
|
||||
}
|
||||
|
||||
/* 2. Flex 布局:让样地卡片和样木弹窗并排 */
|
||||
.popup-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px; /* 间距 */
|
||||
}
|
||||
|
||||
/* 3. 样地卡片样式 */
|
||||
.popup-card {
|
||||
width: 300px;
|
||||
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;
|
||||
width: 280px;
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
border: 1px solid #dcdfe6;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
flex-shrink: 0; /* 防止被挤压 */
|
||||
}
|
||||
|
||||
/* 样木详情页头部 */
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* 4. 样木弹窗包装器 */
|
||||
.tree-wrapper {
|
||||
z-index: 2002;
|
||||
}
|
||||
</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>
|
||||
276
src/components/TreePopup.vue
Normal file
276
src/components/TreePopup.vue
Normal 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"><</el-button>
|
||||
<span class="page-info">{{ currentIndex + 1 }} / {{ total }}</span>
|
||||
<el-button size="small" circle :disabled="currentIndex === total - 1" @click="nextItem" class="nav-btn">></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>
|
||||
Reference in New Issue
Block a user