连接小班接口

This commit is contained in:
2025-11-27 20:18:03 +08:00
parent b841481243
commit df06a9e60e
5 changed files with 821 additions and 242 deletions

0
public/data/sps.json Normal file
View File

70
src/assets/geoserver.js Normal file
View File

@@ -0,0 +1,70 @@
const GEOSERVER_CONFIG = {
baseUrl: "http://8.130.135.159:8000/geoserver/lydc/wms",
typeName: "lydc:NYTB",
};
/**
* 获取图斑数据 (WFS请求)
*/
export const fetchPlotGeoJSON = async () => {
// 1. 构建 WFS 请求 URL
const params = new URLSearchParams({
service: "",
version: "1.0.0",
request: "GetFeature",
typeName: GEOSERVER_CONFIG.typeName,
outputFormat: "application/json",
// 强制返回 WGS84 经纬度,防止坐标跑偏
srsName: "EPSG:3857",
});
try {
const response = await fetch(`${GEOSERVER_CONFIG.baseUrl}?${params.toString()}`);
if (!response.ok) {
throw new Error(`GeoServer 请求失败: ${response.status}`);
}
const geoJsonData = await response.json();
// 2. 数据清洗与映射 (Mapping)
// GeoServer 返回的属性名通常是数据库字段(如 "xb_type", "img_url"
// 我们需要把它们映射回你前端代码里用的 "type", "images" 等
const mappedFeatures = geoJsonData.features.map((feature) => {
const p = feature.properties;
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(',') : [],
},
};
});
return {
type: "FeatureCollection",
features: mappedFeatures,
};
} catch (error) {
console.error("图斑数据加载失败:", error);
// 出错时返回空集合,保证地图不崩
return { type: "FeatureCollection", features: [] };
}
};

View File

@@ -1,48 +1,72 @@
<template>
<div class="map-wrapper">
<div id="header">
<span class="title">天津市林草资源专项调查数据展示系统</span>
</div>
<div id="map" ref="mapContainer" class="map-container"></div>
<div id="dateSelectItem" class="date-panel">
<span class="date-label">数据日期</span>
<el-date-picker
v-model="selectedDate"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
@change="handleDateChange"
size="default"
/>
</div>
<div class="legend-panel">
<Legend :legendData="legendItems" />
<div class="control-item group-item">
<label class="group-header">
<input type="checkbox" v-model="isStatLayerVisible" />
<span>统计数据 ({{ currentLevel === 'district' ? '区级' : '街道' }})</span>
</label>
<IndicatorSelector
:indicators="Object.keys(indicatorData)"
v-model="selectedIndicator"
/>
<!-- 返回按钮 -->
<button
v-if="currentLevel === 'street'"
class="back-btn"
@click="backToDistrict"
>
<div v-if="isStatLayerVisible" class="group-body">
<IndicatorSelector
:indicators="Object.keys(indicatorData)"
v-model="selectedIndicator"
/>
</div>
</div>
<div class="control-item">
<label>
<input type="checkbox" v-model="isPlotLayerVisible" />
所有图斑
</label>
</div>
<div class="control-item">
<label>
<input type="checkbox" />
样地图斑
</label>
</div>
<button v-if="currentLevel === 'street'" class="back-btn" @click="backToDistrict">
返回区级
</button>
</div>
</div>
<PlotPopup
:visible="popupVisible"
:data="popupData"
:position="popupPos"
@enter-sample="showSamples"
@enter-side="showSideTrees"
/>
<SamplePopup
:visible="samplePopupVisible"
:data="samplePopupData"
:position="samplePopupPos"
/>
<PlotPopup :visible="popupVisible" :data="popupData" :position="popupPos" @enter-sample="showSamples"
@enter-side="showSideTrees" @close="popupVisible = false" />
<SamplePopup :visible="samplePopupVisible" :data="samplePopupData" :position="samplePopupPos" />
</template>
<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";
import IndicatorSelector from "./IndicatorSelector.vue";
import Legend from "./Legend.vue";
import { plotGeoJSON, samplePlots, sideTrees } from "../assets/mockPlotData.js";
import { samplePlots, sideTrees } from "../assets/mockPlotData.js";
import PlotPopup from "./PlotPopup.vue";
import SamplePopup from "./SamplePopup.vue";
import bbox from "@turf/bbox";
@@ -50,17 +74,80 @@ import bbox from "@turf/bbox";
const popupVisible = ref(false);
const popupData = ref({});
const popupPos = ref({ x: 0, y: 0 });
const isPlotLayerVisible = ref(false);
const isStatLayerVisible = ref(true);
// 天津市行政区划数据(区级)
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";
// 区级指标数据
const indicatorData =ref({
完成状态:{},
图斑面积:{},
图斑数量:{},
})
// 状态管理
const selectedIndicator = ref("完成状态");
const currentLevel = ref("district"); // 当前层级
const currentDistrict = ref(""); // 当前区名
const mapContainer = ref(null);
let map = null;
const legendItems = ref([]);
let allStreetsData = null;
watch(isStatLayerVisible, () => {
updateStatLayerState();
});
watch(isPlotLayerVisible, (newVal) => {
if (newVal) {
showPlotLayer();
} else {
hidePlotLayer();
}
});
const selectedDate = ref(new Date().toISOString().split('T')[0]); // 默认今天
// ✅ 新增:日期变更处理函数
function handleDateChange(val) {
console.log("📅 日期已切换为:", val);
// 在这里添加你的逻辑,例如:
// 1. 重新请求统计接口
// fetchDistrictStatus(val);
// 2. 刷新 WMS 图层 (如果 WMS 支持 TIME 参数)
// refreshWmsLayer(val);
}
// ✅ 可选:禁用未来日期
function disabledDate(time) {
return time.getTime() > Date.now();
}
function updateStatLayerState() {
const isVisible = isStatLayerVisible.value ? 'visible' : 'none';
if (currentLevel.value === 'district') {
// 如果在区级:显示区,隐藏街道
if (map.getLayer("tianjin-fill")) map.setLayoutProperty("tianjin-fill", "visibility", isVisible);
if (map.getLayer("tianjin-outline")) map.setLayoutProperty("tianjin-outline", "visibility", isVisible);
if (map.getLayer("tianjin-label")) map.setLayoutProperty("tianjin-label", "visibility", isVisible);
// 确保街道层隐藏
if (map.getLayer("street-fill")) map.setLayoutProperty("street-fill", "visibility", "none");
if (map.getLayer("street-outline")) map.setLayoutProperty("street-outline", "visibility", "none");
} else if (currentLevel.value === 'street') {
// 如果在街道级:隐藏区,显示街道
if (map.getLayer("tianjin-fill")) map.setLayoutProperty("tianjin-fill", "visibility", "none");
if (map.getLayer("tianjin-outline")) map.setLayoutProperty("tianjin-outline", "visibility", "none");
if (map.getLayer("tianjin-label")) map.setLayoutProperty("tianjin-label", "visibility", "none");
// 控制街道层
if (map.getLayer("street-fill")) map.setLayoutProperty("street-fill", "visibility", isVisible);
if (map.getLayer("street-outline")) map.setLayoutProperty("street-outline", "visibility", isVisible);
}
}
async function fetchDistrictStatus(){
console.log("🔥 正在尝试发起请求...");
@@ -86,34 +173,40 @@ async function fetchDistrictStatus(){
}
}
// 模拟街道指标(真实项目应从接口获取)
const streetIndicatorData = {
完成状态: {
光复道街道: "已完成",
建昌道街道: "未完成",
王串场街道: "未开始",
},
图斑面积: {
光复道街道: 200,
建昌道街道: 350,
王串场街道: 120,
},
图斑数量: {
光复道街道: 40,
建昌道街道: 55,
王串场街道: 30,
},
};
// 街道指标
const streetIndicatorData = ref({
完成状态:{},
图斑面积:{},
图斑数量:{},
});
// 状态管理
const selectedIndicator = ref("完成状态");
const currentLevel = ref("district"); // 当前层级
const currentDistrict = ref(""); // 当前区名
async function fetchStreetStats(districtName) {
try {
console.log(`🔥 正在请求【${districtName}】的街道数据...`);
const url = `/api/stats/street?district=${encodeURIComponent(districtName)}`;
const response = await fetch(url);
const res = await response.json();
const mapContainer = ref(null);
let map = null;
const legendItems = ref([]);
let allStreetsData = null;
if (res.code === 0 && res.data) {
streetIndicatorData.value = {
完成状态: res.data.completionStatus || {},
图斑面积: res.data.plotArea || {},
图斑数量: res.data.plotCount || {},
};
console.log("✅ 街道数据获取成功:", streetIndicatorData.value);
if (currentLevel.value === 'street') {
// 为了确保地图源(Source)已经创建,可以加一个小延时,或者直接调用
// 这里的 'street' 对应 updateMapColors 里的 sourceId判断
updateMapColors('street');
}
} else {
console.warn("街道数据返回异常:", res);
}
} catch (error) {
console.error("获取街道指标失败:", error);
}
}
// 加载街道
async function loadAllStreets() {
@@ -122,7 +215,84 @@ async function loadAllStreets() {
allStreetsData = await res.json();
}
}
const MEDIA_BASE_URL = '/photos';
async function fetchPlotData(nyxbh, xiang) {
try {
// 1. 构建带参数的 URL
const url = `/api/stats/plot/attr?nyxbh=${encodeURIComponent(nyxbh)}&xiang=${encodeURIComponent(xiang)}`;
console.log("🚀 发起后端详情请求:", url);
// 2. 发起请求
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json'
// 如果有 token记得在这里加 'Authorization': 'Bearer ...'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const resJson = await response.json();
console.log("📦 后端返回的数据:", resJson);
// 3. 校验后端返回的业务状态码
if (resJson.code === 0) {
// 处理数据(特别是媒体路径)
return processPlotData(resJson.data);
} else {
console.error('业务报错:', resJson.message);
return null;
}
} catch (error) {
console.error('请求失败:', error);
return null;
}
}
function processPlotData(data) {
if (!data) return null;
// 1. 处理 Shape 数据 (它是字符串格式的 JSON可能需要转对象)
let geoJson = null;
try {
if (data.shape) {
geoJson = JSON.parse(data.shape);
}
} catch (e) {
console.error('Shape 解析失败', e);
}
// 2. 处理媒体列表
const images = [];
const videos = [];
if (data.mediaPathList && Array.isArray(data.mediaPathList)) {
data.mediaPathList.forEach(path => {
// 拼接完整 URL
const fullUrl = `${MEDIA_BASE_URL}${path}`;
// 简单通过后缀区分类型 (转小写比较)
const lowerPath = path.toLowerCase();
if (lowerPath.endsWith('.jpg') || lowerPath.endsWith('.png') || lowerPath.endsWith('.jpeg')) {
images.push(fullUrl);
} else if (lowerPath.endsWith('.avi') || lowerPath.endsWith('.mp4')) {
videos.push(fullUrl);
}
});
}
// 返回处理后的对象,方便前端直接使用
return {
...data, // 保留原始属性 (id, cun, linZhong 等)
geoJson, // 解析后的地理坐标对象
images, // 只有图片链接的数组
videos // 只有视频链接的数组
};
}
// 定义点击区级多边形的处理函数
async function handleDistrictClick(e) {
const districtName = e.features[0].properties.name;
@@ -144,13 +314,13 @@ function handleMapClick(e) {
// 初始化地图
onMounted(async () => {
console.log("Vue组件已挂载");
fetchDistrictStatus();
console.log("初始化地图");
map = new maplibregl.Map({
container: mapContainer.value,
style: {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
tdt_vec: {
type: "raster",
@@ -178,6 +348,18 @@ onMounted(async () => {
},
center: [117.2, 39.13],
zoom: 8,
transformRequest: (url, resourceType) => {
if (url.includes('/geoserver')) {
return {
url: url,
headers: {
// 将 admin:geoserver 转为 Base64 认证头
'Authorization': 'Basic ' + btoa('admin:geoserver')
}
}
}
return { url };
},
});
const res = await fetch(districtUrl);
@@ -190,14 +372,34 @@ onMounted(async () => {
id: "tianjin-fill",
type: "fill",
source: "tianjin",
paint: { "fill-color": ["get", "color"], "fill-opacity": 0.6 },
layout: { visibility: isStatLayerVisible.value ? 'visible' : 'none' },
paint: { "fill-color": ["coalesce", ["get", "color"], "#b6e3d8"], "fill-opacity": 0.9 },
});
map.addLayer({
id: "tianjin-outline",
type: "line",
source: "tianjin",
paint: { "line-color": "#333", "line-width": 1 },
layout: { visibility: isStatLayerVisible.value ? 'visible' : 'none' },
paint: { "line-color": "#ffffff", "line-width": 1 },
});
map.addLayer({
id: "tianjin-label",
type: "symbol",
source: "tianjin",
layout: { visibility: isStatLayerVisible.value ? 'visible' : 'none' },
layout: {
"text-field": ["get", "name"], // 获取 properties 中的 name
"text-size": 14, // 文字大小
"text-font": ["Open Sans Regular"], // 指定字体(必须与 glyphs 对应)
"text-anchor": "center", // 文字居中
"text-allow-overlap": false // 避免文字重叠
},
paint: {
"text-color": "#000000", // 文字颜色:黑色
"text-halo-color": "#ffffff", // 文字描边:白色(为了看清)
"text-halo-width": 2 // 描边宽度
}
});
updateMapColors();
@@ -205,19 +407,7 @@ onMounted(async () => {
// 点击区多边形,下钻街道级
map.off("click", "tianjin-fill", handleDistrictClick);
map.on("click", "tianjin-fill", handleDistrictClick);
// 点击空白返回
map.off("click", handleMapClick);
map.on("click", handleMapClick);
});
// 缩放监听14级以上显示图斑
map.on("zoom", () => {
const zoom = map.getZoom();
if (zoom >= 14) {
showPlotLayer();
} else {
hidePlotLayer();
}
});
});
@@ -227,18 +417,26 @@ function updateMapColors(level = currentLevel.value) {
const data = map.getSource(sourceId)?._data;
if (!data) return;
const indicator = level === "district" ? indicatorData.value : streetIndicatorData;
const indicator = level === "district" ? indicatorData.value : streetIndicatorData.value;
const selected = selectedIndicator.value;
// 防止接口还没返回数据时报错
if (!indicator[selected]) return;
if (selected === "完成状态") {
const colors = { 完成: "#4CAF50", 未完成: "#FFC107", 开始: "#BDBDBD" };
const colors = { 采集: "#4CAF50", 已采集未开始: "#FFC107", 发布: "#BDBDBD" };
data.features.forEach((f) => {
const name = f.properties.name;
let name;
if (level === 'district') {
name = f.properties.name; // 区级 GeoJSON 还是叫 name
} else {
name = f.properties.; // 街道级 GeoJSON 叫 "乡"
}
// 增加一个去空格处理
if (name) name = name.trim();
const val = indicator[selected][name];
f.properties.color = colors[val] || "#ccc";
f.properties.color = colors[val] || "#BDBDBD";
});
map.getSource(sourceId).setData(data);
legendItems.value = Object.entries(colors).map(([k, v]) => ({
@@ -253,9 +451,17 @@ function updateMapColors(level = currentLevel.value) {
const getColor = (v) =>
colors[Math.floor(((v - min) / (max - min)) * (colors.length - 1))];
data.features.forEach((f) => {
const name = f.properties.name;
let name;
if (level === 'district') {
name = f.properties.name;
} else {
name = f.properties.;
}
if (name) name = name.trim();
const v = indicator[selected][name];
f.properties.color = v ? getColor(v) : "#ccc";
f.properties.color = (v !== undefined && v !== null) ? getColor(v) : "#ccc";
});
map.getSource(sourceId).setData(data);
legendItems.value = colors.map((c, i) => {
@@ -268,8 +474,10 @@ function updateMapColors(level = currentLevel.value) {
// 下钻显示街道级
async function showStreetLevel(districtName) {
currentLevel.value = "street";
currentDistrict.value = districtName;
await loadAllStreets();
fetchStreetStats(districtName);
// ✅ 筛选出该区所有街道
const filteredFeatures = allStreetsData.features.filter(
(f) => f.properties. === districtName
@@ -303,9 +511,13 @@ async function showStreetLevel(districtName) {
id: "street-fill",
type: "fill",
source: "street",
layout: {
// ✅ 新增:初始化时就根据复选框决定可见性
visibility: isStatLayerVisible.value ? 'visible' : 'none'
},
paint: {
"fill-color": ["get", "color"], // 根据指标设色
"fill-opacity": 0.65,
"fill-color": "#b3e0e6", // 根据指标设色
"fill-opacity": 0.8,
},
});
@@ -313,20 +525,24 @@ async function showStreetLevel(districtName) {
id: "street-outline",
type: "line",
source: "street",
paint: { "line-width": 1, "line-color": "#333" },
layout: {
// ✅ 新增:初始化时就根据复选框决定可见性
visibility: isStatLayerVisible.value ? 'visible' : 'none'
},
paint: { "line-width": 1, "line-color": "#ffffff" },
});
currentLevel.value = "street";
currentDistrict.value = districtName;
updateMapColors("street");
updateStatLayerState();
// ✅ 隐藏区级图层
if (map.getLayer("tianjin-fill"))
map.setLayoutProperty("tianjin-fill", "visibility", "none");
if (map.getLayer("tianjin-outline"))
map.setLayoutProperty("tianjin-outline", "visibility", "none");
if (map.getLayer("tianjin-label"))
map.setLayoutProperty("tianjin-label", "visibility", "none");
// ✅ 自动缩放到该区所有街道范围
const extent = bbox(streetGeo); // [minX, minY, maxX, maxY]
@@ -350,47 +566,103 @@ function backToDistrict() {
map.removeLayer("street-outline");
map.removeSource("street");
}
map.setLayoutProperty("tianjin-fill", "visibility", "visible");
map.setLayoutProperty("tianjin-outline", "visibility", "visible");
currentLevel.value = "district";
updateStatLayerState();
map.flyTo({ center: [117.2, 39.13], zoom: 8 });
updateMapColors("district");
}
// 点击图斑显示弹窗
function handlePlotClick(e) {
const feature = e.features[0];
let prop = feature.properties;
async function handlePlotClick(e) {
console.log("🖱️ 点击坐标:", e.lngLat);
// --- 解析属性 ---
let images = prop.images;
let videos = prop.videos;
// 1. 基础检查
if (!map.getLayer("plot-raster-layer") || map.getLayoutProperty("plot-raster-layer", "visibility") === 'none') return;
if (typeof images === "string") {
try {
images = JSON.parse(images);
} catch {
images = [];
}
}
if (typeof videos === "string") {
try {
videos = JSON.parse(videos);
} catch {
videos = [];
}
// 2. 避让矢量图层 (样地/树)
const targetLayers = ["samples-fill", "sideTrees"];
const existingLayers = targetLayers.filter(id => map.getLayer(id) && map.getLayoutProperty(id, "visibility") !== 'none');
if (existingLayers.length > 0) {
const vectorFeatures = map.queryRenderedFeatures(e.point, { layers: existingLayers });
if (vectorFeatures.length > 0) return;
}
// 计算屏幕像素位置
const canvas = map.getCanvas().getBoundingClientRect();
const pos = map.project(e.lngLat);
popupPos.value = {
x: pos.x + canvas.left,
y: pos.y + canvas.top,
};
const buffer = 0.0001;
const lng = e.lngLat.lng;
const lat = e.lngLat.lat;
popupData.value = { ...prop, images, videos };
popupVisible.value = true;
// 构造一个小小的查询框,单位是度
const bbox = [
lng - buffer,
lat - buffer,
lng + buffer,
lat + buffer
].join(",");
const baseUrl = "/geoserver/lydc/wms";
const layerName = "lydc:NYTB";
const params = new URLSearchParams({
SERVICE: "WMS",
VERSION: "1.1.1",
REQUEST: "GetFeatureInfo",
FORMAT: "image/png",
TRANSPARENT: "true",
QUERY_LAYERS: layerName,
LAYERS: layerName,
// ✅ 关键 1: 告诉服务器我用经纬度查
SRS: "EPSG:4326",
// ✅ 关键 2: 传过去的是经纬度
BBOX: bbox,
// ✅ 关键 3: 欺骗服务器,说这一小块区域是一张 101x101 的图,我点了正中间
WIDTH: 101,
HEIGHT: 101,
X: 50,
Y: 50,
INFO_FORMAT: "application/json",
FEATURE_COUNT: 1,
EXCEPTIONS: "application/vnd.ogc.se_xml"
});
try {
const url = `${baseUrl}?${params.toString()}`;
const wmsRes = await fetch(url);
if (!wmsRes.ok) throw new Error(`HTTP ${wmsRes.status}`);
const wmsData = await wmsRes.json();
if (wmsData.features && wmsData.features.length > 0) {
const wmsProp = wmsData.features[0].properties;
const nyxbh = wmsProp.nyxbh || wmsProp.NYXBH;
let xiang = wmsProp.xiang || wmsProp.XIANG;
if (!xiang && (wmsProp.cun || wmsProp.CUN)) {
const cunCode = String(wmsProp.cun || wmsProp.CUN);
xiang = cunCode.substring(0, 9);
}
if (nyxbh && xiang) {
const detailData = await fetchPlotData(nyxbh, xiang);
if (detailData) {
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;
popupVisible.value = true;
} else {
alert("未查询到详细数据");
}
}
} else {
console.log("❌ 未命中图斑 (请确认点击位置是否有数据)");
}
} catch (error) {
console.error("查询出错:", error);
}
}
// 点击空白关闭弹窗
@@ -402,73 +674,58 @@ function handleMapClickClosePopup(e) {
}
// === 图斑层控制 ===
// === 图斑层控制 ===
let cachedPlotData = null;
function showPlotLayer() {
// 已存在则直接显示
if (map.getLayer("plot-fill")) {
map.setLayoutProperty("plot-fill", "visibility", "visible");
map.setLayoutProperty("plot-outline", "visibility", "visible");
} else {
map.addSource("plot", { type: "geojson", data: plotGeoJSON });
const typeColors = {
乔木林: "#4CAF50",
灌木林: "#8BC34A",
草地: "#CDDC39",
};
map.addLayer({
id: "plot-fill",
type: "fill",
source: "plot",
paint: {
"fill-color": [
"match",
["get", "type"],
"乔木林",
typeColors["乔木林"],
"灌木林",
typeColors["灌木林"],
"草地",
typeColors["草地"],
"#ccc",
],
"fill-opacity": 0.7,
},
});
map.addLayer({
id: "plot-outline",
type: "line",
source: "plot",
paint: { "line-color": "#333", "line-width": 1 },
// 3. 如果 Source 不存在,添加 WMS 源
if (!map.getSource("plot-wms-source")) {
const LAYER_NAME = "lydc:NYTB";
map.addSource("plot-wms-source", {
type: "raster",
tiles: [
`/geoserver/lydc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=${LAYER_NAME}&FORMAT=image/png&TRANSPARENT=true&WIDTH=256&HEIGHT=256&SRS=EPSG:3857&BBOX={bbox-epsg-3857}`
],
tileSize: 256
});
}
// 隐藏行政区划与街道层
if (map.getLayer("tianjin-fill"))
map.setLayoutProperty("tianjin-fill", "visibility", "none");
if (map.getLayer("tianjin-outline"))
map.setLayoutProperty("tianjin-outline", "visibility", "none");
if (map.getLayer("street-fill"))
map.setLayoutProperty("street-fill", "visibility", "none");
if (map.getLayer("street-outline"))
map.setLayoutProperty("street-outline", "visibility", "none");
// 4. 添加 Raster 图层(如果不存在)
if (!map.getLayer("plot-raster-layer")) {
map.addLayer({
id: "plot-raster-layer",
type: "raster",
source: "plot-wms-source",
paint: {
"raster-opacity": 1.0
}
});
} else {
// 5. 如果图层已存在,确保它是可见的
map.setLayoutProperty("plot-raster-layer", "visibility", "visible");
}
// 更新图例为图斑类型
legendItems.value = [
{ color: "#4CAF50", range: "乔木林" },
{ color: "#8BC34A", range: "灌木林" },
{ color: "#CDDC39", range: "草地" },
];
map.off("click", handlePlotClick);
// 重新绑定点击事件
map.on("click", handlePlotClick);
// === 小班点击事件 ===
map.off("click", "plot-fill", handlePlotClick);
map.off("click", handleMapClickClosePopup);
map.on("click", "plot-fill", handlePlotClick);
map.on("click", handleMapClickClosePopup);
// 改变鼠标样式为小手,提示用户可以点击
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");
}
});
}
// === 显示样地 ===
// === 显示样地Polygon ===
function showSamples() {
@@ -527,26 +784,18 @@ function showSideTrees() {
}
function hidePlotLayer() {
// 隐藏图斑
if (map.getLayer("plot-fill")) {
map.setLayoutProperty("plot-fill", "visibility", "none");
map.setLayoutProperty("plot-outline", "visibility", "none");
map.setLayoutProperty("samples-fill", "visibility", "none");
map.setLayoutProperty("samples-outline", "visibility", "none");
map.setLayoutProperty("sideTrees", "visibility", "none");
// ✅ 隐藏 WMS 栅格图
if (map.getLayer("plot-raster-layer")) {
map.setLayoutProperty("plot-raster-layer", "visibility", "none");
}
map.off("click", handlePlotClick);
// 恢复鼠标样式
map.getCanvas().style.cursor = '';
// (保留) 隐藏之前的样地、四旁树等逻辑...
if (map.getLayer("samples-fill")) map.setLayoutProperty("samples-fill", "visibility", "none");
if (map.getLayer("samples-outline")) map.setLayoutProperty("samples-outline", "visibility", "none");
if (map.getLayer("sideTrees")) map.setLayoutProperty("sideTrees", "visibility", "none");
// 恢复行政区划或街道层显示
if (currentLevel.value === "district") {
map.setLayoutProperty("tianjin-fill", "visibility", "visible");
map.setLayoutProperty("tianjin-outline", "visibility", "visible");
} else if (currentLevel.value === "street") {
map.setLayoutProperty("street-fill", "visibility", "visible");
map.setLayoutProperty("street-outline", "visibility", "visible");
}
// 恢复行政区划/街道的图例
updateMapColors(currentLevel.value);
}
// === 样地弹窗状态 ===
@@ -600,10 +849,50 @@ onBeforeUnmount(() => {
.html,
body,
#app,
.map-wrapper,
#header {
/* 固定高度,比百分比更稳定 */
height: 60px;
width: 100%;
/* ✅ 核心颜色:深森林绿 */
background-color: #5db862;
color: white;
/* 布局:左对齐,垂直居中 */
display: flex;
align-items: center;
padding: 0 24px; /*哪怕文字很少,左右留白也好看 */
box-sizing: border-box; /* 防止padding撑破宽度 */
/* 装饰:底部微弱阴影,增加层次感 */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
z-index: 10; /* 保证压在地图上面 */
}
/* 标题文字样式 */
.title {
font-size: 20px;
font-weight: 600; /* 半粗体 */
letter-spacing: 1px; /* 字间距稍微拉开一点,更大气 */
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
/* 3. 地图区域:自动填满剩余空间 */
.map-wrapper {
flex: 1; /* 自动占据 Header 剩下的高度 */
position: relative;
width: 100%;
background-color: #eef2f3; /* 地图加载前的背景色 */
}
.map-container {
width: 100%;
height: 100%;
}
.map-wrapper,
.map-container {
width: 100%;
height: 95%;
margin: 0;
padding: 0;
}
@@ -644,4 +933,45 @@ body,
.back-btn:hover {
background: #1565c0;
}
.control-item {
background: white;
padding: 8px;
border-radius: 4px;
box-shadow: 0 0 4px rgba(0,0,0,0.2);
margin-bottom: 5px;
cursor: pointer;
}
.control-item label {
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.date-panel {
position: absolute;
top: 80px; /* 距离 Header 下方 20px */
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;
}
.date-label {
font-size: 14px;
font-weight: 600;
color: #333;
}
/* 调整 Element Plus 输入框的宽度 */
:deep(.el-date-editor.el-input) {
width: 160px;
}
</style>

View File

@@ -4,44 +4,85 @@
class="plot-popup"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
>
<el-card shadow="always" class="popup-card">
<h4>{{ data.name }}</h4>
<p>类型{{ data.type }}</p>
<p>面积{{ data.area }} ha</p>
<p>采集人员{{ data.collector }}</p>
<p>优势树种{{ data.species }}</p>
<el-card shadow="always" class="popup-card" :body-style="{ padding: '10px', position: 'relative' }">
<span class="close-btn" @click="$emit('close')">×</span>
<!-- 图片与视频轮播 -->
<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"
<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>
<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"
>
<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>
<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" @click="$emit('enter-sample')">
进入样地
<el-button type="primary" size="small" plain @click="$emit('enter-sample')">
查看样地详情
</el-button>
<el-button type="success" size="small" @click="$emit('enter-side')">
进入四旁树
<el-button type="success" size="small" plain @click="$emit('enter-side')">
四旁树
</el-button>
</div>
</el-card>
@@ -50,11 +91,8 @@
<script setup>
import { computed } from "vue";
import { ElCard, ElButton, ElCarousel, ElCarouselItem } 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";
// 不需要引入 ElIcon 或 Close直接用原生 CSS
import { ElCard, ElButton, ElCarousel, ElCarouselItem, ElTag, ElImage } from "element-plus";
const props = defineProps({
visible: Boolean,
@@ -62,38 +100,172 @@ const props = defineProps({
position: { type: Object, default: () => ({ x: 0, y: 0 }) },
});
defineEmits(['enter-sample', 'enter-side', 'close']);
// 格式化日期,只显示 YYYY-MM-DD
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 : [];
let imgCounter = 0;
return [
...imgs.map((src) => ({ type: "image", src })),
...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 : [];
});
</script>
<style scoped>
.plot-popup {
position: absolute;
z-index: 999;
z-index: 2000;
/* 调整弹窗位置锚点,使其位于点击点的上方居中 */
transform: translate(-50%, -100%);
margin-top: -15px; /* 留一点间距,别挡住点击点 */
pointer-events: auto; /* 确保弹窗可交互 */
}
/* 加上小三角箭头 */
.plot-popup::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 6px 6px 0;
border-style: solid;
border-color: #fff transparent transparent transparent;
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.1));
}
.popup-card {
width: 280px;
font-size: 13px;
width: 300px; /* 稍微加宽一点 */
border-radius: 8px;
overflow: visible; /* 允许箭头显示 */
}
.media {
/* ✅ 2. 新增:关闭按钮样式 */
.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;
}
.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: 220px;
object-fit: cover;
border-radius: 8px;
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: 8px;
margin-top: 12px;
padding-top: 8px;
border-top: 1px dashed #eee;
}
</style>
.popup-actions .el-button {
flex: 1;
}
</style>

View File

@@ -8,11 +8,18 @@ export default defineConfig({
proxy: {
// 只要你请求以 /api 开头,就会被转发到目标服务器
'/api': {
target: 'http://192.168.1.254:9001',
target: 'http://192.168.1.196:9001',
changeOrigin: true,
// 如果后端路径本身就有 /api就不需要 rewrite
// 如果你希望把 /api 前缀去掉再转发,就打开下面这行:
// rewrite: (path) => path.replace(/^\/api/, ''),
},
'/photos':{
target: 'http://8.130.135.159:8084',
changeOrigin: true,
},
'/geoserver': {
// 注意:这里必须写 /geoserver 结尾,且没有拼写错误
target: 'http://8.130.135.159:8000/geoserver',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/geoserver/, '')
},
},
},