lxf 1 неделя назад
Родитель
Сommit
f6a566580e

+ 1 - 1
src/api/config.js

@@ -1,7 +1,7 @@
 const newServer = VE_ENV.SERVER;
 const pyServer = VE_ENV.PYSERVER;
 const newPathServer = VE_ENV.NEW_SERVER;
-const oldServer = "https://birdseye-api.sysuimars.com/";
+const oldServer = "https://birdseye-api.feiniaotech.sysuimars.cn/";
 const fosterServer = "https://foster-api.sysuimars.com/";
 
 export default {

+ 9 - 0
src/api/modules/farm.js

@@ -0,0 +1,9 @@
+import config from "../config";
+
+export default {
+  /** 蓝区列表(执行区域) */
+  blueRegionList: {
+    url: config.base_url + "z_foundation_blue_zone/blueRegionList",
+    type: "get",
+  },
+};

+ 4 - 11
src/api/modules/system.js

@@ -1,16 +1,9 @@
-/**
- * API 模块示例
- * 新增业务接口:在 modules 下新建 xxx.js,自动挂到 VE_API.xxx
- *
- * @example
- * this.VE_API.system.ping()
- */
 import config from "../config";
 
 export default {
-  // 示例接口,可删除或按实际后端替换
-  ping: {
-    url: config.base_dev_url + "health",
-    type: "get",
+  /** 地图底图等配置 */
+  getCfg: {
+    url: config.base_url + "cfg/get",
+    type: "post",
   },
 };

BIN
src/assets/page/map-point.png


+ 2 - 1
src/common/ol_common.js

@@ -53,6 +53,7 @@ function crtLayerWMTS(wmtsData, opacity, projection) {
     matrixSet: wmtsData.matrixSet,
     format: "tiles",
     projection: projection,
+    crossOrigin: "anonymous",
     tileGrid: new WMTSTileGrid({
       origin: olExtent.getTopLeft(projectionExtent),
       resolutions: resolutions,
@@ -65,7 +66,7 @@ function crtLayerWMTS(wmtsData, opacity, projection) {
   const layer = new TileLayer({
     zIndex: wmtsData.zIndex,
     source: source,
-    visible: false,
+    visible: true,
     opacity: opacity ?? 1,
   });
   layer.id = wmtsData.layer + "_" + wmtsData.matrixSet;

+ 78 - 0
src/utils/ol-map/Bounds.js

@@ -0,0 +1,78 @@
+import * as extent  from "ol/extent"
+import LngLat from "./LngLat"
+/**
+ * @description KMap.Bounds 经纬度矩形范围类
+ */
+class Bounds{
+  /**
+   * @param {KMap.LngLat} southWest 矩形范围西南角坐标,必填,格式new KMap.LngLat()
+   * @param {KMap.LngLat} northEast 矩形范围东北角坐标,必填,格式new KMap.LngLat()
+   */
+  constructor(southWest,northEast){
+		const vm = this
+		vm.southWest = southWest
+		vm.northEast = northEast
+    let mapSouthWest = [southWest.getLng(),southWest.getLat()]
+    let mapNorthEast = [northEast.getLng(),northEast.getLat()]
+    let bounds = new extent.boundingExtent([mapSouthWest,mapNorthEast])
+    this.bounds = bounds
+  }
+
+	/**
+	 * @description 判断指定点坐标是否在矩形范围内
+	 * @param {KMap.LngLat} point 经纬度点,KMap.LngLat格式,必填
+	 * @returns {boolean}在矩形范围内返回true,否则返回false
+	 */
+  contains(point) {
+		const vm = this
+		if( (point.getLng() >= vm.southWest.getLng() && point.getLng() <= vm.northEast.getLng())
+			&& (point.getLat() >= vm.southWest.getLat() && point.getLat() <= vm.northEast.getLat()) )
+		{
+			return true
+		}else{
+			return false
+		}
+  }
+
+  /**
+   * @description 获取中心点坐标
+   * @returns {KMap.LngLat}中心点坐标,KMap.LngLat格式
+   */
+	getCenter() {
+		const vm = this
+		let lng = (vm.southWest.getLng() + vm.northEast.getLng()) / 2
+		let lat = (vm.southWest.getLat() + vm.northEast.getLat()) / 2
+		var center = new LngLat(lng,lat)
+		return center
+	}
+
+	/**
+	 * @description 获取西南角坐标
+	 * @returns {KMap.LngLat}西南角坐标,KMap.LngLat格式
+	 */
+	getSouthWest() {
+		const vm = this
+		return vm.southWest
+	}
+
+	/**
+	 * @description 获取东北角坐标
+	 * @returns {KMap.LngLat}东北角坐标,KMap.LngLat格式
+	 */
+	getNorthEast() {
+		const vm = this
+		return vm.northEast
+	}
+
+	/**
+	 * @description 以字符串形式返回地物对象的矩形范围
+	 * @returns {String}西南角经度、西南角纬度:东北角经度、东北角纬度
+	 */
+  toString() {
+		const vm = this
+        return vm.southWest.getLng() + "," + vm.southWest.getLat() + ":"
+               + vm.northEast.getLng() + "," + vm.northEast.getLat()
+  }
+}
+
+export default Bounds

+ 63 - 0
src/utils/ol-map/Check.js

@@ -0,0 +1,63 @@
+import * as Info from './Info'
+class Check{
+  static lngLat(lng,lat){
+    let msg = ''
+    let result = false
+    if(lat == null || lng == null || lat == undefined || lng == undefined){
+      msg = Check.addHeader("经纬度不能为null")
+      return Check.message(msg,result)
+    }
+    if(Check.isNumber(lat) || Check.isNumber(lng)){
+      msg = Check.addHeader("经纬度应为数字")
+      return Check.message(msg,result)
+    }
+    if(lat<-90 || lat>90){
+      msg = Check.addHeader("纬度lat应该大于-90小于90")
+      return Check.message(msg,result)
+    }
+    if(lng<-180 || lng>180){
+      msg = Check.addHeader("经度lng应该大于-180小于180")
+      return  Check.message(msg,result)
+    }
+    if(msg == ''){
+      result = true
+    }
+    return Check.message(msg,result)
+  }
+
+  static isNumber(str){
+    
+    if(str == null || undefined){
+      return false
+    }
+    
+    if((typeof str=='string')&&str.constructor==String){
+      return false
+    }
+    
+    if(!isNaN(str)){
+      return false
+    }
+    return true
+  }
+  static notEmpty(name,str){
+    let result = false;
+    if(str == null || undefined){
+      result =  false
+    }
+    
+    if((typeof str=='string') && str.constructor==String && str !=''){
+      result =  true
+    }else{
+      result =  false
+    }
+    return Check.message(Check.addHeader(name+"必须为字符串且不能为空"),result);
+  }
+  static addHeader(str){
+    return Info.version+":"+str
+  }
+  static message(msg,isPass){
+    return {msg:msg,isPass:isPass}
+  }
+}
+export default Check

+ 145 - 0
src/utils/ol-map/Common.js

@@ -0,0 +1,145 @@
+import Size from './Size'
+import Pixel from './Pixel'
+import LngLat from './LngLat'
+import Bounds from './Bounds'
+import Check from './Check'
+import * as olProj from 'ol/proj';
+/**
+ * @description KMap.Common类 通用静态方法
+ */
+class Common{
+	static ShowLevel = [1,22]
+	/**
+	 *@description 底图Zoom限制
+	*/
+	static BaseLayerZoom = [1,22]
+	/**
+	 * @description 利通地图像素转OpenLayers地图像素
+	 * @param {KMap.Pixel} pixel KMap.Pixel格式的像素,必填
+	 * @returns {Array} OpenLayers格式的像素,包含两个元素的数组[x,y]
+	*/
+	static KMapPixel2MapPixel(pixel){
+		let mapPixel = [pixel.getX(),pixel.getY()]
+		return mapPixel
+	}
+
+	/**
+	 * @description OpenLayers地图像素转利通地图像素
+	 * @param {Array} pixel OpenLayers格式的像素,包含两个元素的数组[x,y],必填
+	 * @returns {KMap.Pixel} KMap.Pixel格式的像素
+	*/
+	static MapPixel2KMapPixel(pixel) {
+		let ltPixel = new Pixel(pixel[0], pixel[1])
+		return ltPixel
+	}
+
+	/**
+	 * @description 利通地图像素尺寸转OpenLayers地图像素尺寸
+	 * @param {KMap.Size} size KMap.Size格式的尺寸,必填
+	 * @returns {Array} OpenLayers地图像素尺寸,包含两个元素的数组[width,height]
+	*/
+	static KMapSize2MapSize(size) {
+		let mapSize = [size.getWidth(), size.getHeight()]
+		return mapSize
+	}
+
+	/**
+	 * @description OpenLayers地图像素尺寸转利通地图像素尺寸
+	 * @param {Array} size OpenLayers地图像素尺寸,包含两个元素的数组[width,height],必填
+	 * @returns {KMap.Size} 格式的尺寸
+	*/
+	static MapSize2KMapSize(size) {
+		let ltSize = new Size(size[0], size[1])
+		return ltSize
+	}
+
+	/**
+	 * @description 利通地图经纬度转OpenLayers地图经纬度
+	 * @param {KMap.LngLat} lnglat KMap.LngLat格式的经纬度,必填
+	 * @returns {Array} OpenLayers的经纬度格式,包含两个元素的数组[lng,lat]
+	*/
+	static KMapLngLat2MapLngLat(lnglat) {
+		let alnglat = [lnglat.getLng(),lnglat.getLat()]
+		return alnglat
+	}
+
+	/**
+	 * @description OpenLayers地图经纬度转利通地图经纬度
+	 * @param {Array} lnglat OpenLayers的经纬度格式,包含两个元素的数组[lng,lat],必填
+	 * @returns {KMap.LngLat} KMap.LngLat格式的经纬度
+	*/
+	static MapLngLat2KMapLngLat(lnglat) {
+		let ltlnglat = new LngLat(lnglat[0], lnglat[1])
+		return ltlnglat
+	}
+
+	/**
+	 * @description 利通地图经纬度矩形范围转OpenLayers地图经纬度矩形范围
+	 * @param {KMap.Bounds} bounds KMap.Bounds对象,必填
+	 * @returns {Array} 西南角经度、西南角纬度、东北角经度、东北角纬度构成的数组
+	*/
+	static KMapBounds2MapBounds(bounds) {
+		let array = new Array()
+		let southWest = bounds.getSouthWest()
+		let northEast = bounds.getNorthEast()
+		array.push(southWest.getLng())
+		array.push(southWest.getLat())
+		array.push(northEast.getLng())
+		array.push(northEast.getLat())
+		return array
+	}
+
+	/**
+	 * @description OpenLayers地图经纬度矩形范围转利通地图经纬度范围
+	 * @param {Array} bounds 西南角经度、西南角纬度、东北角经度、东北角纬度构成的数组,必填
+	 * @returns {KMap.Bounds} KMap.Bounds类型对象
+	*/
+	static MapBounds2KMapBounds(bounds) {
+		let southWest = [bounds[0],bounds[1]]
+		let northEast = [bounds[2],bounds[3]]
+		southWest = new LngLat(southWest[0],southWest[1])
+		northEast = new LngLat(northEast[0],northEast[1])
+		bounds = new Bounds(southWest,northEast)
+		return bounds
+	}
+	static toWGS84LngLat(map,coordinate){
+		map.getCoor
+		return olProj.transform(coordinate,map.getProjection(),"EPSG:4326")
+	}
+
+	/**
+	 * @description 扩展JSON对象属性
+	 * @param {JSON} des 目标JSON对象,必填
+	 * @param {JSON} src 源JSON对象,必填
+	 * @param {boolean} override 是否覆盖属性,选填
+	 * @returns {JSON} 目标JSON对象
+	*/
+	static extend(des, src, override){
+		if(src instanceof Array){
+			for(let i = 0, len = src.length; i < len; i++)
+				Common.extend(des, src[i], override)
+		}
+		for( let i in src){
+			if(override || !(i in des)){
+				des[i] = src[i]
+			}
+		}
+		return des
+	}
+	static checkLngLat(lng,lat){
+		let info = Check.lngLat(lng,lat)
+    if(!info.isPass){
+      throw new Error(info.msg)
+    }
+	}
+
+	static notEmpty(name,str){
+		let info = Check.notEmpty(name,str)
+    if(!info.isPass){
+      throw new Error(info.msg)
+    }
+	}
+
+}
+
+export default Common

+ 9 - 0
src/utils/ol-map/Enum.js

@@ -0,0 +1,9 @@
+/**
+ * @description KMap.LayerTypeEnum 底图类型枚举
+*/
+export const LayerTypeEnum = {
+  'ARCGISTile':"ARCGISTile",
+  'GaoDeTile':"GaoDeTile",
+  "WGS84Tile":"WGS84Tile",
+  "BaiDuTile":"BaiDuTile"
+}

+ 19 - 0
src/utils/ol-map/Info.js

@@ -0,0 +1,19 @@
+/**
+ * @module KMap/Info
+ * @description api基本信息
+ */
+
+/**
+ * @const
+ * @version
+ * @type {string}
+ * @description 版本
+ */
+export const version = "KMap 1.0 Base On OpenLayers6.14.1"
+
+/**
+ * @const
+ * @type {string}
+ * @description 原生引擎名称
+ */
+export const srcApiName = "OpenLayers6.14.1"

+ 11 - 0
src/utils/ol-map/KBaseObject.js

@@ -0,0 +1,11 @@
+import Map from  './Map'
+class KBaseObject{
+  constructor(mapInstance){
+    const vm = this
+		//利通map实例
+		vm.mapInstance = mapInstance || Map.Instance
+		//获取ol map对象
+		vm.map = vm.mapInstance.map
+  }
+}
+export default KBaseObject

+ 15 - 0
src/utils/ol-map/KMap.js

@@ -0,0 +1,15 @@
+import Map from "./Map";
+import Common from "./Common";
+import WMTSLayer from "./WMTSLayer";
+import VectorLayer from "./VectorLayer";
+import XYZLayer from "./XYZLayer";
+import VectorStyle from "./VectorStyle";
+
+export {
+  Map,
+  Common,
+  WMTSLayer,
+  XYZLayer,
+  VectorLayer,
+  VectorStyle,
+};

+ 78 - 0
src/utils/ol-map/LngLat.js

@@ -0,0 +1,78 @@
+import Common from "./Common"
+/**
+ * @description KMap.LngLat 经纬度
+*/
+class LngLat{
+  /**
+   * @param {number} lng 纬度
+   * @param {number} lat 经度
+   * @constructor
+   */
+  constructor(lng,lat){
+    Common.checkLngLat(lng,lat)
+    let maplnglat = [Number(lng),Number(lat)]
+    this.lngLat = maplnglat
+  }
+
+
+	/**
+	 * @description 当前经纬度坐标值经度移动w,纬度移动s,得到新的坐标。 经度向右移为正值,纬度向上移为正值,单位为°
+	 * @param {number} w 经度移动量
+	 * @param {number} s 纬度移动量
+	 */
+  offset(w, s) {
+    let lng = this.lngLat[0]+w
+    let lat = this.lngLat[1]+s
+    return new LngLat(lng,lat)
+  }
+
+  /**
+   * @description 当前经纬度和传入经纬度之间的地面距离,单位为米----暂无该方法
+   * @param {number} lnglat 经纬度
+   */
+  distance(lnglat) {
+    return null
+  }
+
+  /**
+   * @description 获取经度
+   * @returns {number} 返回经度
+   */
+	getLng() {
+		let lng = this.lngLat[0]
+		return lng
+	}
+
+	/**
+	 * @description 获取纬度
+	 * @returns {number} 返回纬度
+	 */
+	getLat() {
+		let lat = this.lngLat[1]
+		return lat
+	}
+
+	/**
+	 * @description 判断当前坐标对象与传入坐标对象是否相等
+	 * @param {KMap.LngLat} lnglat 格式的经纬度,必填
+	 * @returns {boolean} 坐标相等返回true,坐标不相等返回false
+	 */
+  equals(lnglat) {
+    if(lnglat.getLng() == this.lngLat[0] && lnglat.getLat() == this.lngLat[1]){
+      return true
+    }
+    else{
+      return false
+    }
+  }
+
+  /**
+   * @description LngLat对象以字符串的形式返回。
+   * @returns {String} 返回经纬度格式的字符串,用逗号连接
+   */
+  toString() { 
+    return this.lngLat[0] + "," + this.lngLat[1]
+  }
+}
+
+export default LngLat

+ 127 - 0
src/utils/ol-map/Map.js

@@ -0,0 +1,127 @@
+import OLMap from "ol/Map";
+import View from "ol/View";
+import * as proj from "ol/proj";
+import * as interaction from "ol/interaction";
+import "ol/ol.css";
+import "./css/KMap.css";
+import Common from "./Common";
+import VectorLayer from "./VectorLayer";
+import WMTSLayer from "./WMTSLayer";
+import XYZLayer from "./XYZLayer";
+import config from "@/api/config.js";
+
+/**
+ * KMap.Map —— 与 feiniao-pc-vue 用法对齐的精简实现
+ * new KMap.Map(target, level, lng, lat, projection, minZoom, maxZoom, ...)
+ */
+class Map {
+  static Instance = null;
+
+  constructor(id, zoomLevel, lng, lat, projection, minZoom, maxZoom) {
+    this.defaultCursor = "default";
+    Map.Instance = this;
+
+    if (projection) {
+      projection = proj.get(projection);
+    }
+    projection = projection || proj.get("EPSG:4326");
+
+    let lnglat = [lng, lat];
+    if (projection.getCode() === "EPSG:3857") {
+      lnglat = proj.fromLonLat(lnglat);
+    }
+
+    Common.checkLngLat(lng, lat);
+
+    this.view = new View({
+      center: lnglat,
+      zoom: zoomLevel,
+      minZoom: minZoom || Common.ShowLevel[0],
+      maxZoom: maxZoom || Common.ShowLevel[1],
+      projection,
+    });
+
+    this.map = new OLMap({
+      interactions: interaction.defaults().extend([new interaction.DragRotateAndZoom()]),
+      target: id,
+      layers: [],
+      view: this.view,
+      controls: [],
+    });
+
+    this.initBaseLayer(projection);
+    this.initBusinessLayer();
+  }
+
+  /** 与 pc-vue 一致:img_wmts_mkt + cva_wmts_mkt + 默认 XYZ */
+  async initBaseLayer(projection) {
+    try {
+      const getCfg = window.VE_API?.system?.getCfg;
+      if (getCfg) {
+        const img_wmts = await getCfg({ k: "img_wmts_mkt", resultType: "json" });
+        const cva_wmts = await getCfg({ k: "cva_wmts_mkt", resultType: "json" });
+        const imgData = typeof img_wmts?.data === "string" ? JSON.parse(img_wmts.data) : img_wmts?.data;
+        const cvaData = typeof cva_wmts?.data === "string" ? JSON.parse(cva_wmts.data) : cva_wmts?.data;
+        if (imgData?.url) {
+          this.tdtImgLayer = new WMTSLayer(imgData, projection, this);
+        }
+        if (cvaData?.url) {
+          this.cva_torLayer = new WMTSLayer(cvaData, projection, this);
+        }
+      }
+    } catch (e) {
+      console.warn("[KMap.Map] 底图 WMTS 配置加载失败", e);
+    }
+
+    const xyz2 = config.base_img_url3 + "map/lby/{z}/{x}/{y}.png";
+    this.addXYZLayer(xyz2, { minZoom: 15, maxZoom: 22 });
+  }
+
+  addXYZLayer(url, options) {
+    return new XYZLayer(url, options, 3, this);
+  }
+
+  initBusinessLayer() {
+    const vm = this;
+    const map = vm.map;
+    vm.markerLayer = new VectorLayer("defaultMarkerLayer", 101);
+    vm.polyLineLayer = new VectorLayer("defaultPolylineLayer", 101);
+    vm.polygonLayer = new VectorLayer("defaultPolygonLayer", 1000);
+    vm.labelLayer = new VectorLayer("defaultLabelLayer", 99);
+
+    map.addLayer(vm.polygonLayer.layer);
+    map.once("postrender", () => {
+      map.addLayer(vm.markerLayer.layer);
+      map.addLayer(vm.polyLineLayer.layer);
+      map.addLayer(vm.labelLayer.layer);
+    });
+  }
+
+  addLayer(layer) {
+    this.map.addLayer(layer);
+  }
+
+  fit(geometryOrExtent, options) {
+    this.view.fit(geometryOrExtent, options);
+  }
+
+  on(type, listener) {
+    return this.map.on(type, listener);
+  }
+
+  updateSize() {
+    this.map.updateSize();
+  }
+
+  destroy() {
+    if (this.map) {
+      this.map.setTarget(null);
+      this.map = null;
+    }
+    if (Map.Instance === this) {
+      Map.Instance = null;
+    }
+  }
+}
+
+export default Map;

+ 55 - 0
src/utils/ol-map/Pixel.js

@@ -0,0 +1,55 @@
+/**
+ * @description KMap.Pixel 像素类
+*/
+class Pixel{
+  /**
+   * @description 像素类构造函数
+   * @param {number} x X像素,必填
+   * @param {number} y Y像素,必填
+   */
+  constructor(x,y){
+    let mapPixel = [Number(x),Number(y)];
+    this.pixel = mapPixel;
+  }
+
+  /**
+   * @description 获得X方向像素坐标
+   * @returns {number} 返回X方向像素坐标
+   */
+  getX() { 
+    return this.pixel[0]; 
+  }
+
+  /**
+   * @description 获得Y方向像素坐标
+   * @returns {number} 返回Y方向像素坐标
+   */
+  getY() { 
+    return this.pixel[1]; 
+  }
+
+  /**
+   * @description 当前像素坐标与传入像素坐标是否相等,必填
+   * @returns {boolean} 相等返回true,不相等返回false
+   */
+  equals(point) {
+      if(point.getX() == this.pixel[0] && point.getY() == this.pixel[1])
+      {
+        return true;
+      }
+      else
+      {
+        return false;
+      }
+  }
+
+  /**
+   * @description 以字符串形式返回像素坐标对象
+   * @returns {String} 像素坐标字符串,用逗号连接
+   */
+  toString() { 
+    return this.pixel[0] + "," + this.pixel[1] ; 
+  }
+}
+
+export default Pixel

+ 39 - 0
src/utils/ol-map/Size.js

@@ -0,0 +1,39 @@
+/**
+ * @description KMap.Size 大小类
+ */
+class Size{
+  /**
+   * @param {number} width 宽度
+   * @param {number} height 高度
+   * @constructor
+   */
+  constructor(width,height){
+    let mapSize = [Number(width),Number(height)]
+    this.size = mapSize
+  }
+
+  /**
+  * @description 获得宽度
+  * @returns {number} 宽度
+  */
+  getWidth() { 
+    return this.size[0]
+  };
+
+  /**
+  * @description 获取高度
+  * @returns {number} 高度
+  */
+  getHeight() { 
+    return this.size[1]
+  }
+
+  /**
+  * @description 以字符串形式返回尺寸大小对象
+  * @returns {number} 像素尺寸字符串,用逗号连接
+  */
+  toString() { 
+    return this.size[0] + "," + this.size[1]
+  }
+}
+export default Size

+ 91 - 0
src/utils/ol-map/VectorLayer.js

@@ -0,0 +1,91 @@
+import Layer from 'ol/layer/Vector'
+import Source from 'ol/source/Vector'
+import Common from './Common'
+import Select from 'ol/interaction/Select.js';
+import {singleClick} from 'ol/events/condition'
+/**
+ * @description KMap.VectorLayer 矢量图层
+ */
+class VectorLayer {
+  /**
+   * @param {string} name 图层名称
+   * @param {string} zIndex 图层层级
+  */
+  constructor(name,zIndex,options){
+    let source = new Source({
+      crossOrigin:'anonymous',
+    })
+    let ShowLevel = Common.ShowLevel
+    let minZoom = ShowLevel[0]
+    let maxZoom = ShowLevel[1]
+    let style = null;
+    if(options && options.source){
+      source = options.source
+    }
+    if(options && options.minZoom){
+      minZoom = options.minZoom
+    }
+    if(options && options.maxZoom){
+      maxZoom = options.maxZoom
+    }
+    if(options && options.style){
+      style = options.style
+    }
+    let layer = new Layer({
+      source:source,
+      zIndex:zIndex,
+      minZoom:minZoom,
+      maxZoom:maxZoom,
+    })
+    layer.set('name', name)
+    if(style){
+      layer.setStyle(style)
+    }
+    this.layer = layer
+    this.source = source
+  }
+  setMaxZoom(maxZoom){
+    this.layer.setMaxZoom(maxZoom);
+  }
+  setMinZoom(minZoom){
+    this.layer.setMinZoom(minZoom);
+  }
+  addFeature(feature){
+    this.source.addFeature(feature)
+  }
+  refresh(){
+    this.source.refresh()
+  }
+  getFeatureById(id){
+    return this.source.getFeatureById(id)
+  }
+  addSingleSelect(callback,map,style){
+    let option = {
+        condition:singleClick,
+        layers:[this.layer],
+        multi:false
+    };
+    if(style){
+      option["style"] = style
+    }
+    this.singleSelect = new Select(option)
+    this.singleSelect.on("select",callback)
+    map.addInteraction(this.singleSelect)
+  }
+  addToggleSelect(callback,map,style){
+    let option = {
+      condition:singleClick,
+      toggleCondition: singleClick,
+      layers:[this.layer],
+      multi:true
+    };
+    if(style){
+      option["style"] = style
+    }
+    this.toggleSelect = new Select(option)
+    this.toggleSelect.on("select",callback)
+    map.addInteraction(this.toggleSelect)
+  }
+}
+
+export default VectorLayer

+ 119 - 0
src/utils/ol-map/VectorStyle.js

@@ -0,0 +1,119 @@
+
+import LTBaseObject from './KBaseObject'
+import XYZ from 'ol/source/XYZ'
+import Tile from 'ol/layer/Tile'
+import Common from './Common'
+import Style from "ol/style/Style";
+import Fill from "ol/style/Fill";
+import Stroke from "ol/style/Stroke";
+import Text from "ol/style/Text";
+import Icon from "ol/style/Icon";
+import Circle from "ol/style/Circle";
+/**
+ * @description 样式类
+ */
+class VectorStyle{
+	constructor(){
+		this.cachePointSimpleStyle = {}
+	}
+	getLineStyle(fillColor, strokeColor, strokeWidth){
+		let style = new Style({
+			stroke: new Stroke({
+				color: strokeColor,
+				width: strokeWidth || 1,
+			}),
+		});
+		return style;
+	}
+
+	getPolygonStyle(fillColor, strokeColor, strokeWidth){
+		let style = new Style({
+			fill: new Fill({
+				color: fillColor
+			}),
+			stroke: new Stroke({
+				color: strokeColor,
+				width: strokeWidth || 1,
+			}),
+		});
+		return style;
+	}
+	getPointStyle(src, scale, anchor, text, font, textColor){
+		let textObj = null;
+		text &&	(textObj = new Text({
+			text:text,
+			stroke: new Stroke({
+				color: textColor,
+				width: 1,
+			}),
+			fill: new Fill({
+				color: textColor,
+			}),
+			font:font
+		}))
+		let style = new Style({
+			image: new Icon({
+				text:textObj,
+				src:src,
+				scale:scale,
+				anchor:anchor,
+			})
+		});
+		return style
+	}
+
+	/**
+	 *
+	 * @param radius 半径
+	 * @param fillColor 填充颜色
+	 * @param strokeColor 边框颜色
+	 * @param strokeWidth 边框宽度
+	 * @returns {Style}
+	 */
+	getPointSimpleStyle(radius, fillColor, strokeColor, strokeWidth){
+		let key = radius + fillColor + strokeColor + strokeWidth
+		if(this.cachePointSimpleStyle[key]){
+			return this.cachePointSimpleStyle[key]
+		}
+		let style = new Style({
+			image: new Circle({
+				radius: radius,                             // 半径
+				stroke: new Stroke({           // 边界样式
+					color: strokeColor,                    // 边界颜色
+					width: strokeWidth                           // 边界宽度
+				}),
+				fill: new Fill({               // 填充样式
+					color: fillColor                       // 填充颜色
+				})
+			})
+		});
+		this.cachePointSimpleStyle[key] = style
+		return style
+	}
+
+	/**
+	 *
+	 * @param text
+	 * @param fillColor 填充颜色
+	 * @param strokeColor 边框颜色
+	 * @param strokeWidth 边框宽度
+	 * @returns {Style}
+	 */
+	getPointTextStyle(text, fillColor, strokeColor, strokeWidth, fontSize){
+		let style = new Style({
+			text: new Text({
+				text:text,
+				stroke: new Stroke({
+					color: strokeColor,
+					width: strokeWidth,
+				}),
+				fill: new Fill({
+					color: fillColor,
+				}),
+				font: fontSize+"px sans-serif"
+			}),
+		});
+		return style
+	}
+}
+export default VectorStyle

+ 112 - 0
src/utils/ol-map/WMTSLayer.js

@@ -0,0 +1,112 @@
+import LTBaseObject from './KBaseObject'
+import TileLayer from 'ol/layer/Tile'
+import sourceWMTS from 'ol/source/WMTS';
+import WMTSTileGrid from 'ol/tilegrid/WMTS';
+import * as olExtent from 'ol/extent';
+/**
+ * @description KMap.WMTSLayer WMS图层类
+*/
+class WMTSLayer extends LTBaseObject{
+	/**
+	 * @description 构造函数
+	 * @param {String} url wms图层服务地址
+	 * @param {LTMap.Map} [mapInstance=null] map对象,单地图的时候可不传,多地图时候需要传
+	 * @memberof WMTSLayer
+	 */
+	constructor(wmtsData,projection,mapInstance = null){
+		super(mapInstance)
+		const vm = this;
+		vm.initLayer(wmtsData,projection)
+	}
+
+	/**
+		* @description 初始化ImageLayer
+		* @memberof WMTSLayer
+		*/
+	initLayer(wmtsData,projection){
+		const vm = this;
+		let projectionExtent = projection.getExtent();
+    let size = olExtent.getWidth(projectionExtent) / 256;
+    let resolutions = new Array(19);
+    let matrixIds = new Array(19);
+    for (var z = 1; z < 19; ++z) {
+        // generate resolutions and matrixIds arrays for this WMTS
+        resolutions[z] = size / Math.pow(2, z);
+        matrixIds[z] = z;
+    }
+
+    let source = new sourceWMTS({
+        url: wmtsData.url,
+        layer: wmtsData.layer,
+        matrixSet: wmtsData.matrixSet,
+        format: 'tiles',
+        projection: projection,
+		crossOrigin:'anonymous',
+        tileGrid: new WMTSTileGrid({
+            origin: olExtent.getTopLeft(projectionExtent),
+            resolutions: resolutions,
+            matrixIds: matrixIds
+        }),
+        style: 'default',
+        wrapX: true
+    })
+    let layer = new TileLayer({
+        zIndex:wmtsData.zIndex,
+        source: source,
+        // visible: false,
+    });
+    layer.id = wmtsData.layer+"_"+wmtsData.matrixSet;
+		vm.layer = layer;
+		vm.source = source;
+		vm.map.addLayer(vm.layer)
+    return layer;
+    //地图加载WMS图层
+	}
+
+  /**
+	* @description 添加WMS图层到地图
+	* @memberof WMTSLayer
+	*/
+  add(){
+		const vm = this
+		if(!vm.state) {
+			vm.map.addLayer(vm.layer)
+			vm.state = true
+		}
+	}
+
+	/**
+	 * @description 从当前地图中移除WMS图层
+	 * @memberof WMTSLayer
+	 */
+	remove() {
+		const vm = this
+		if(vm.state) {
+			vm.map.removeLayer(vm.layer)
+			vm.state = false
+		}
+	}
+
+	/**
+	 * @description 显示WMS图层数据
+	 * @memberof WMTSLayer
+	 */
+	show(){
+		const vm = this
+		if(vm.state) {
+			vm.layer.setVisible(true)
+		}
+	}
+
+	/**
+	 * @description 隐藏WMS图层数据
+	 * @memberof WMTSLayer
+	 */
+	hide(){
+		const vm = this
+		if(vm.state) {
+			vm.layer.setVisible(false)
+		}
+	}
+}
+export default WMTSLayer

+ 116 - 0
src/utils/ol-map/XYZLayer.js

@@ -0,0 +1,116 @@
+
+import LTBaseObject from './KBaseObject'
+import XYZ from 'ol/source/XYZ'
+import Tile from 'ol/layer/Tile'
+import Common from './Common'
+/**
+ * @description LTMap.XYZLayer XYZ图层类
+*/
+class XYZLayer extends LTBaseObject{
+	/**
+	 * @description 构造函数
+	 * @param {String} url XYZ图层服务地址
+	 * @param {LTMap.Map} [mapInstance=null] map对象,单地图的时候可不传,多地图时候需要传
+	 * @memberof XYZLayer
+	 */
+	constructor(url,options,zIndex,mapInstance = null){
+    super(mapInstance)
+		const vm = this
+		vm.initXYZLayer(url,options,zIndex)
+  }
+	/**
+	 * @description 切片加载XYZ图层
+	 * @param {*} url
+	 * @param {*} layerName
+	 * @memberof XYZLayer
+	 */
+	 initXYZLayer(url,options,zIndex){
+		var minZoom = Common.BaseLayerZoom[0];
+		var maxZoom = Common.BaseLayerZoom[1];
+		if(options && options.minZoom != undefined){
+			minZoom = options.minZoom
+		}
+		if(options && options.maxZoom != undefined){
+			maxZoom = options.maxZoom
+		}
+		if(options && options.zIndex != undefined){
+			zIndex = options.zIndex
+		}
+		const vm = this
+		vm.source = new XYZ({
+			crossOrigin:'anonymous',
+      		url : url
+    	})
+
+		vm.layer = new Tile({
+			source:vm.source,
+			maxZoom:maxZoom,
+			minZoom:minZoom,
+			zIndex:zIndex,
+			extent:options.extent || null
+		})
+		if(options && options.padding != undefined){
+			vm.layer.set('padding',options.padding)
+		}
+
+		vm.map.addLayer(vm.layer)
+	}
+	setProperty(properties){
+		const vm = this;
+		vm.properties = properties;
+	}
+	getProperty(){
+		const vm = this;
+		return vm.properties?vm.properties:null;
+	}
+  /**
+	* @description 添加XYZ图层到地图
+	* @memberof XYZLayer
+	*/
+  add(){
+		const vm = this
+		if(!vm.state) {
+			vm.map.addLayer(vm.layer)
+			vm.state = true
+		}
+	}
+
+	/**
+	 * @description 从当前地图中移除XYZ图层
+	 * @memberof XYZLayer
+	 */
+	remove() {
+		const vm = this
+		if(vm.state) {
+			vm.map.removeLayer(vm.layer)
+			vm.state = false
+		}
+	}
+
+	/**
+	 * @description 显示XYZ图层数据
+	 * @memberof XYZLayer
+	 */
+	show(){
+		const vm = this
+		vm.layer.setVisible(true)
+	}
+
+	/**
+	 * @description 隐藏XYZ图层数据
+	 * @memberof XYZLayer
+	 */
+	hide(){
+		const vm = this
+		vm.layer.setVisible(false)
+	}
+
+	/**
+	 * @description 缩放到过滤后的地图要素对应范围
+	 * @memberof XYZLayer
+	 */
+	zoomTo() {
+    const vm = this
+	}
+}
+export default XYZLayer

+ 88 - 0
src/utils/ol-map/css/KMap.css

@@ -0,0 +1,88 @@
+.ol-mouse-position-KMap{
+  color:#66b166;
+  position: absolute;
+  font-weight: 500;
+  bottom:8px;
+  right:8px;
+}
+/*鹰眼控件展开时的控件外边框*/
+.myOverview:not(.ol-collapsed){
+  border:1px solid black;
+}
+/*鹰眼控件地图容器边框样式*/
+.myOverview .ol-overviewmap-map{
+  border:none;
+  width:200px;
+  height: 100px;
+}
+/*鹰眼控件中显示当前窗口区域的边框样式*/
+.myOverview .ol-overviewmap-box{
+  border:2px solid red;
+}
+ /*鹰眼控件展开时其控件按钮图标的样式*/
+.myOverview:not(.ol-collapsed) button{
+  bottom:auto;
+  left:auto;
+  right:1px;
+  top:1px;
+}
+.ol-overviewmap{
+  left: auto !important;
+  right:0.5em;
+  bottom: 0.5em;
+}
+/*2D视图中添加自定义视图旋转控制器的样式*/
+.brightmap2d-rotate-control-custom {
+  position: absolute;
+  bottom: 90px;
+  right: 9px;
+  width: 52px;
+  height: 54px;
+  background: url(./rotate.png) 0% 0% / 266px no-repeat;
+}
+
+.center2d-button-custom {
+  position: absolute;
+  outline: none;
+  border: none;
+  background: url(./rotate.png) -56px -4px / 266px no-repeat;
+  cursor: pointer;
+  left: 19px;
+  top: 4px;
+  width: 14px;
+  height: 44px;
+  transform: rotate(0deg);
+}
+
+.right2d-button-custom {
+  position: absolute;
+  outline: none;
+  border: none;
+  background: url(./rotate.png) -75px -5px / 266px no-repeat;
+  right: 2px;
+  top: 5px;
+  width: 15px;
+  height: 42px;
+  transform: scaleX(-1);
+}
+
+.left2d-button-custom {
+  position: absolute;
+  outline: none;
+  border: none;
+  background: url(./rotate.png) -75px -5px / 266px no-repeat;
+  left: 2px;
+  top: 5px;
+  width: 15px;
+  height: 42px;
+}
+
+.left2d-button-custom:hover {
+  cursor: pointer;
+  background: url(./rotate.png) -89px -5px / 266px no-repeat;
+}
+
+.right2d-button-custom:hover {
+  cursor: pointer;
+  background: url(./rotate.png) -89px -5px / 266px no-repeat;
+}

+ 175 - 0
src/views/device/DeviceDetailDialog.vue

@@ -0,0 +1,175 @@
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    title="设备详情"
+    width="520px"
+    align-center
+    append-to-body
+    destroy-on-close
+    class="device-detail-dialog"
+    @update:model-value="emit('update:modelValue', $event)"
+  >
+    <div v-if="data" class="detail-body">
+      <div class="detail-row">
+        <span class="detail-label">农机名称:</span>
+        <span class="detail-value">{{ data.name || "-" }}</span>
+      </div>
+
+      <div class="detail-row">
+        <span class="detail-label">农机类型:</span>
+        <div class="detail-value">
+          <span v-if="data.type" class="tag">{{ data.type }}</span>
+          <span v-else>-</span>
+        </div>
+      </div>
+
+      <div class="detail-row">
+        <span class="detail-label">农事类别:</span>
+        <div class="detail-value tags">
+          <span
+            v-for="(item, idx) in data.categories || []"
+            :key="idx"
+            class="tag"
+          >
+            {{ item }}
+          </span>
+          <span v-if="!(data.categories && data.categories.length)">-</span>
+        </div>
+      </div>
+
+      <div class="detail-row">
+        <span class="detail-label">存放点位:</span>
+        <span class="detail-value">{{ data.location || "-" }}</span>
+      </div>
+
+      <div class="detail-row detail-row--top">
+        <span class="detail-label">执行区域:</span>
+        <div class="detail-value">
+          <span v-if="data.areaLabel" class="area-box">
+            <div class="area-text">{{ data.areaLabel }}</div>
+          </span>
+          <span v-else>-</span>
+        </div>
+      </div>
+
+      <div class="detail-row">
+        <span class="detail-label">负责人:</span>
+        <span class="detail-value">{{ data.ownerName || "-" }}</span>
+      </div>
+
+      <div class="detail-row">
+        <span class="detail-label">手机号:</span>
+        <span class="detail-value">{{ data.ownerPhone || "-" }}</span>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button class="btn-edit" @click="handleEdit">编辑信息</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false,
+  },
+  data: {
+    type: Object,
+    default: null,
+  },
+});
+
+const emit = defineEmits(["update:modelValue", "edit"]);
+
+const handleEdit = () => {
+  emit("edit");
+};
+</script>
+
+<style lang="scss" scoped>
+.detail-body {
+  padding: 20px 28px 0 16px;
+  border-top: 1px solid #E5E6EB;
+}
+
+.detail-row {
+  display: flex;
+  align-items: center;
+  min-height: 32px;
+  margin-bottom: 12px;
+
+  &--top {
+    align-items: flex-start;
+  }
+}
+
+.detail-label {
+  flex-shrink: 0;
+  width: 70px;
+  text-align: right;
+  color: #1D2129;
+  font-size: 14px;
+  line-height: 22px;
+}
+
+.detail-value {
+  flex: 1;
+  margin-left: 12px;
+  color: #000;
+  font-size: 14px;
+  line-height: 22px;
+  word-break: break-all;
+}
+
+.tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.tag {
+  display: inline-block;
+  padding: 1px 10px;
+  border-radius: 4px;
+  background: rgba(251, 142, 51, 0.1);
+  color: #FB8E33;
+  font-size: 12px;
+  line-height: 22px;
+}
+
+.area-box {
+}
+
+.area-thumb {
+  display: block;
+  width: 100%;
+  height: 96px;
+  object-fit: cover;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: center;
+  padding-bottom: 8px;
+}
+
+.btn-edit {
+  min-width: 120px;
+  height: 36px;
+  color: $base-color;
+  background: #fff;
+  border-color: $base-color;
+  border-radius: 4px;
+
+  &:hover,
+  &:focus {
+    color: #fff;
+    background: $base-color;
+    border-color: $base-color;
+  }
+}
+</style>

+ 67 - 15
src/views/device/DeviceFormDialog.vue

@@ -62,21 +62,21 @@
       <el-form-item label="存放点位" prop="location" required>
         <div class="field-block">
           <el-input
-            v-if="isEdit"
+            v-if="isEdit || form.location"
             v-model="form.location"
             placeholder="请选择存放点位"
             readonly
           />
           <el-button class="btn-outline" @click="handleSelectLocation">
-            {{ isEdit ? "修改点位" : "选择点位" }}
+            {{ isEdit || form.location ? "修改点位" : "选择点位" }}
           </el-button>
         </div>
       </el-form-item>
 
-      <el-form-item label="执行区域" prop="areaThumb" required>
+      <el-form-item label="执行区域" prop="areaLabel" required>
         <div class="field-block">
-          <div v-if="isEdit" class="area-preview">
-            执行区域区域地图地图地图地图
+          <div v-if="isEdit && form.areaLabel" class="area-preview">
+            {{ form.areaLabel }}
           </div>
           <el-button class="btn-outline" @click="handleSelectArea">
             {{ isEdit ? "修改区域" : "选择区域" }}
@@ -110,11 +110,27 @@
       </div>
     </template>
   </el-dialog>
+
+  <SelectAreaDialog
+    v-model="areaDialogVisible"
+    :farm-id="101532"
+    :selected-ids="form.selectedAreaIds"
+    @confirm="handleAreaConfirm"
+  />
+
+  <SelectLocationDialog
+    v-model="locationDialogVisible"
+    :farm-id="101532"
+    :location-wkt="form.locationWkt"
+    @confirm="handleLocationConfirm"
+  />
 </template>
 
 <script setup>
 import { computed, reactive, ref, watch } from "vue";
 import { ElMessage } from "element-plus";
+import SelectAreaDialog from "./SelectAreaDialog.vue";
+import SelectLocationDialog from "./SelectLocationDialog.vue";
 
 const props = defineProps({
   modelValue: {
@@ -136,6 +152,8 @@ const emit = defineEmits(["update:modelValue", "submit"]);
 const isEdit = computed(() => props.mode === "edit");
 const formRef = ref();
 const submitting = ref(false);
+const areaDialogVisible = ref(false);
+const locationDialogVisible = ref(false);
 
 const typeOptions = ["肥水一体", "水肥一体", "植保无人机", "收割机", "播种机"];
 const categoryOptions = ["类别一", "类别二", "类别三", "类别四", "类别五", "类别六", "类别七"];
@@ -146,7 +164,10 @@ const createEmptyForm = () => ({
   type: "",
   categories: [],
   location: "",
-  areaThumb: "",
+  locationWkt: "",
+  areaLabel: "",
+  selectedAreaIds: [],
+  selectedAreas: [],
   ownerName: "",
   ownerPhone: "",
 });
@@ -174,10 +195,10 @@ const rules = {
       trigger: "change",
     },
   ],
-  areaThumb: [
+  areaLabel: [
     {
       validator: (_rule, _value, callback) => {
-        if (!form.areaThumb) callback(new Error("请选择执行区域"));
+        if (!form.selectedAreaIds?.length) callback(new Error("请选择执行区域"));
         else callback();
       },
       trigger: "change",
@@ -201,7 +222,10 @@ const fillForm = (data) => {
     type: data?.type || "",
     categories: [...(data?.categories || [])],
     location: data?.location || "",
-    areaThumb: data?.areaThumb,
+    locationWkt: data?.locationWkt || "",
+    areaLabel: data?.areaLabel || "",
+    selectedAreaIds: [...(data?.selectedAreaIds || [])],
+    selectedAreas: [...(data?.selectedAreas || [])],
     ownerName: data?.ownerName || "",
     ownerPhone: data?.ownerPhone || "",
   });
@@ -223,19 +247,31 @@ const close = () => emit("update:modelValue", false);
 
 const handleClosed = () => {
   submitting.value = false;
+  areaDialogVisible.value = false;
+  locationDialogVisible.value = false;
   formRef.value?.resetFields?.();
   Object.assign(form, createEmptyForm());
 };
 
 const handleSelectLocation = () => {
-  form.location = form.location || "位置位置位置位置位置";
-  ElMessage.success(isEdit.value ? "已修改点位(占位)" : "已选择点位(占位)");
+  locationDialogVisible.value = true;
+};
+
+const handleLocationConfirm = (point) => {
+  form.locationWkt = point.wkt;
+  form.location = point.label;
   formRef.value?.validateField?.("location");
 };
 
 const handleSelectArea = () => {
-  ElMessage.success(isEdit.value ? "已修改区域(占位)" : "已选择区域(占位)");
-  formRef.value?.validateField?.("areaThumb");
+  areaDialogVisible.value = true;
+};
+
+const handleAreaConfirm = ({ areas, ids, label }) => {
+  form.selectedAreas = areas;
+  form.selectedAreaIds = ids;
+  form.areaLabel = label;
+  formRef.value?.validateField?.("areaLabel");
 };
 
 const handleSubmit = async () => {
@@ -249,7 +285,10 @@ const handleSubmit = async () => {
         type: form.type,
         categories: [...form.categories],
         location: form.location,
-        areaThumb: form.areaThumb,
+        locationWkt: form.locationWkt,
+        areaLabel: form.areaLabel,
+        selectedAreaIds: [...form.selectedAreaIds],
+        selectedAreas: [...form.selectedAreas],
         ownerName: form.ownerName,
         ownerPhone: form.ownerPhone,
       };
@@ -265,7 +304,8 @@ const handleSubmit = async () => {
 
 <style lang="scss" scoped>
 .device-form {
-  padding: 8px 24px 0 8px;
+  padding: 20px 24px 0 8px;
+  border-top: 1px solid #E5E6EB;
 
   :deep(.el-form-item) {
     margin-bottom: 24px;
@@ -296,6 +336,18 @@ const handleSubmit = async () => {
   }
 }
 
+.area-preview {
+  min-width: 148px;
+  max-width: 100%;
+  padding: 10px 12px;
+  border: 1px solid #e5e5e5;
+  border-radius: 2px;
+  background: #f7f7f7;
+  color: #666;
+  font-size: 13px;
+  line-height: 1.5;
+  word-break: break-all;
+}
 
 .dialog-footer {
   display: flex;

+ 159 - 0
src/views/device/SelectAreaDialog.vue

@@ -0,0 +1,159 @@
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    title="选择执行区域"
+    width="920px"
+    align-center
+    append-to-body
+    destroy-on-close
+    class="select-area-dialog"
+    @update:model-value="emit('update:modelValue', $event)"
+    @opened="handleOpened"
+    @closed="handleClosed"
+  >
+    <div v-loading="loading" class="map-wrap">
+      <div ref="mapRef" class="map-el"></div>
+      <div v-if="!loading && regionCount === 0" class="map-empty">暂无区域数据</div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" @click="handleConfirm">确认</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { getCurrentInstance, nextTick, ref } from "vue";
+import { ElMessage } from "element-plus";
+import AreaSelectMap from "./areaSelectMap";
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false,
+  },
+  farmId: {
+    type: [String, Number],
+    default: 101532,
+  },
+  /** 已选区域 id 列表,编辑回显 */
+  selectedIds: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const emit = defineEmits(["update:modelValue", "confirm"]);
+
+const { proxy } = getCurrentInstance();
+const mapRef = ref(null);
+const loading = ref(false);
+const regionCount = ref(0);
+let areaMap = null;
+
+const close = () => emit("update:modelValue", false);
+
+const handleOpened = async () => {
+  await nextTick();
+  if (!mapRef.value) return;
+
+  if (!areaMap) {
+    areaMap = new AreaSelectMap();
+  }
+  await areaMap.init(
+    mapRef.value,
+    "POINT(113.61448114737868 23.585550924763083)"
+  );
+  await loadRegions();
+  areaMap.updateSize();
+};
+
+const loadRegions = async () => {
+  loading.value = true;
+  regionCount.value = 0;
+  try {
+    const res = await proxy.VE_API.farm.blueRegionList({
+      farmId: props.farmId,
+      regionId: "",
+    });
+    const list = Array.isArray(res?.data) ? res.data : [];
+    regionCount.value = list.length;
+    areaMap?.setRegions(list, props.selectedIds);
+    if (!list.length && res?.msg) {
+      ElMessage.warning(res.msg);
+    }
+  } catch (e) {
+    console.error(e);
+    ElMessage.error("区域数据加载失败");
+  } finally {
+    loading.value = false;
+    nextTick(() => areaMap?.updateSize());
+  }
+};
+
+const handleConfirm = () => {
+  const areas = areaMap?.getSelected() || [];
+  if (!areas.length) {
+    ElMessage.warning("请至少选择一个执行区域");
+    return;
+  }
+  emit("confirm", {
+    areas,
+    ids: areas.map((a) => a.id),
+    label: areas.map((a) => a.name).join(","),
+  });
+  close();
+};
+
+const handleClosed = () => {
+  areaMap?.destroy();
+  areaMap = null;
+  regionCount.value = 0;
+};
+</script>
+
+<style lang="scss" scoped>
+.map-wrap {
+  position: relative;
+  width: 100%;
+  height: 520px;
+  background: #1a1a1a;
+  border-radius: 4px;
+  overflow: hidden;
+  cursor: pointer;
+}
+
+.map-el {
+  width: 100%;
+  height: 100%;
+}
+
+.map-empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #999;
+  pointer-events: none;
+  z-index: 2;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: center;
+  gap: 16px;
+  padding-bottom: 4px;
+}
+</style>
+
+<style lang="scss">
+.select-area-dialog {
+  .el-dialog__body {
+    padding: 12px 20px 8px;
+  }
+}
+</style>

+ 183 - 0
src/views/device/SelectLocationDialog.vue

@@ -0,0 +1,183 @@
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    title="选择存放点位"
+    width="920px"
+    align-center
+    append-to-body
+    destroy-on-close
+    class="select-location-dialog"
+    @update:model-value="emit('update:modelValue', $event)"
+    @opened="handleOpened"
+    @closed="handleClosed"
+  >
+    <div v-loading="loading" class="map-wrap">
+      <div ref="mapRef" class="map-el"></div>
+      <div v-if="!loading && regionCount === 0" class="map-empty">暂无区域数据</div>
+      <div class="map-tip">点击地图放置存放位置</div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="close">取消</el-button>
+        <el-button type="primary" @click="handleConfirm">确认</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { getCurrentInstance, nextTick, ref } from "vue";
+import { ElMessage } from "element-plus";
+import { wktCastGeom } from "@/common/ol_common";
+import LocationSelectMap from "./locationSelectMap";
+
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+    default: false,
+  },
+  farmId: {
+    type: [String, Number],
+    default: 101532,
+  },
+  /** 已选点位 WKT,编辑回显 */
+  locationWkt: {
+    type: String,
+    default: "",
+  },
+});
+
+const emit = defineEmits(["update:modelValue", "confirm"]);
+
+const { proxy } = getCurrentInstance();
+const mapRef = ref(null);
+const loading = ref(false);
+const regionCount = ref(0);
+let locationMap = null;
+
+const close = () => emit("update:modelValue", false);
+
+const handleOpened = async () => {
+  await nextTick();
+  if (!mapRef.value) return;
+
+  if (!locationMap) {
+    locationMap = new LocationSelectMap();
+  }
+
+  const initLocation =
+    props.locationWkt || "POINT(113.61448114737868 23.585550924763083)";
+  locationMap.init(mapRef.value, initLocation);
+  await loadRegions();
+  locationMap.updateSize();
+};
+
+const loadRegions = async () => {
+  loading.value = true;
+  regionCount.value = 0;
+  try {
+    const res = await proxy.VE_API.farm.blueRegionList({
+      farmId: props.farmId,
+      regionId: "",
+    });
+    const list = Array.isArray(res?.data) ? res.data : [];
+    regionCount.value = list.length;
+    locationMap?.setRegions(list);
+
+    await nextTick();
+    if (props.locationWkt) {
+      try {
+        const coord = wktCastGeom(props.locationWkt).getFirstCoordinate();
+        locationMap?.setPoint(coord);
+      } catch (e) {
+        locationMap?.setPointToCenter();
+      }
+    } else {
+      locationMap?.setPointToCenter();
+    }
+
+    if (!list.length && res?.msg) {
+      ElMessage.warning(res.msg);
+    }
+  } catch (e) {
+    console.error(e);
+    ElMessage.error("区域数据加载失败");
+  } finally {
+    loading.value = false;
+    nextTick(() => locationMap?.updateSize());
+  }
+};
+
+const handleConfirm = () => {
+  const point = locationMap?.getPoint();
+  if (!point) {
+    ElMessage.warning("请在地图上选择存放点位");
+    return;
+  }
+  emit("confirm", point);
+  close();
+};
+
+const handleClosed = () => {
+  locationMap?.destroy();
+  locationMap = null;
+  regionCount.value = 0;
+};
+</script>
+
+<style lang="scss" scoped>
+.map-wrap {
+  position: relative;
+  width: 100%;
+  height: 520px;
+  background: #1a1a1a;
+  border-radius: 4px;
+  overflow: hidden;
+  cursor: crosshair;
+}
+
+.map-el {
+  width: 100%;
+  height: 100%;
+}
+
+.map-empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #999;
+  pointer-events: none;
+  z-index: 2;
+}
+
+.map-tip {
+  position: absolute;
+  left: 16px;
+  bottom: 16px;
+  z-index: 3;
+  padding: 6px 12px;
+  border-radius: 4px;
+  background: rgba(0, 0, 0, 0.55);
+  color: #fff;
+  font-size: 13px;
+  pointer-events: none;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: center;
+  gap: 16px;
+  padding-bottom: 4px;
+}
+</style>
+
+<style lang="scss">
+.select-location-dialog {
+  .el-dialog__body {
+    padding: 12px 20px 8px;
+  }
+}
+</style>

+ 147 - 0
src/views/device/areaSelectMap.js

@@ -0,0 +1,147 @@
+import * as KMap from "@/utils/ol-map/KMap";
+import config from "@/api/config";
+import { wktCastGeom, newAreaFeature } from "@/common/ol_common";
+import { unByKey } from "ol/Observable";
+
+/** 与 workDetail/areaMap 默认中心一致 */
+const DEFAULT_LOCATION = "POINT(113.61448114737868 23.585550924763083)";
+
+/**
+ * 执行区域选择地图
+ * 创建方式对齐 workDetail/areaMap:new KMap.Map(...)
+ */
+export default class AreaSelectMap {
+  constructor() {
+    const that = this;
+    const vectorStyle = new KMap.VectorStyle();
+    this.vectorStyle = vectorStyle;
+    this.kmap = null;
+    this.clickKey = null;
+
+    this.blueRegionLayer = new KMap.VectorLayer("blueRegionLayer", 99999, {
+      minZoom: 1,
+      maxZoom: 22,
+      style: (f) => {
+        if (f.get("selected")) {
+          return that.vectorStyle.getPolygonStyle(
+            "rgba(251, 142, 51, 0.35)",
+            "#FB8E33",
+            2.5
+          );
+        }
+        return that.vectorStyle.getPolygonStyle(
+          "rgba(0, 0, 0, 0.45)",
+          "rgba(255, 212, 137, 0.85)",
+          1.5
+        );
+      },
+    });
+  }
+
+  /**
+   * @param {HTMLElement} target
+   * @param {string} location WKT
+   */
+  init(target, location = DEFAULT_LOCATION) {
+    this.destroy();
+
+    const level = 16;
+    const coordinate = wktCastGeom(location).getFirstCoordinate();
+
+    // 与 areaMap.js 一致
+    this.kmap = new KMap.Map(
+      target,
+      level,
+      coordinate[0],
+      coordinate[1],
+      null,
+      1,
+      22,
+      "vec",
+      true,
+      true
+    );
+
+    this.kmap.addLayer(this.blueRegionLayer.layer);
+
+    const xyz = config.base_img_url + "map/lby/{z}/{x}/{y}.png";
+    this.kmap.addXYZLayer(xyz, { minZoom: 8, maxZoom: 22 }, 2);
+
+    this.addMapSingleClick();
+    setTimeout(() => this.updateSize(), 80);
+  }
+
+  addMapSingleClick() {
+    const that = this;
+    this.clickKey = this.kmap.on("singleclick", (evt) => {
+      that.kmap.map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
+        if (layer === that.blueRegionLayer.layer) {
+          feature.set("selected", !feature.get("selected"));
+        }
+      });
+    });
+  }
+
+  /**
+   * @param {Array} list
+   * @param {Array<string|number>} selectedIds
+   */
+  setRegions(list = [], selectedIds = []) {
+    if (!this.blueRegionLayer?.source) return;
+    this.blueRegionLayer.source.clear();
+
+    const selectedSet = new Set((selectedIds || []).map((id) => String(id)));
+
+    for (const item of list) {
+      const row = {
+        ...item,
+        wkt: item.geom || item.wkt,
+        id: item.blueZoneCode ?? item.id,
+        name: item.name || item.regionName || item.blueZoneName || String(item.blueZoneCode ?? item.id),
+      };
+      if (!row.wkt) continue;
+
+      // ol_common.newAreaFeature 读 geom 字段
+      const feature = newAreaFeature({ ...row, geom: row.wkt });
+      feature.set("selected", selectedSet.has(String(row.id)));
+      feature.set("name", row.name);
+      this.blueRegionLayer.addFeature(feature);
+    }
+
+    this.fitToRegions();
+  }
+
+  fitToRegions() {
+    const extent = this.blueRegionLayer?.source?.getExtent?.();
+    if (!extent || !isFinite(extent[0]) || !this.kmap) return;
+    this.kmap.fit(extent, { padding: [0, 0, 0, 0], duration: 300, maxZoom: 18 });
+  }
+
+  getSelected() {
+    if (!this.blueRegionLayer?.source) return [];
+    return this.blueRegionLayer.source
+      .getFeatures()
+      .filter((f) => f.get("selected"))
+      .map((f) => ({
+        id: f.get("id") ?? f.getId(),
+        name: f.get("name"),
+        raw: f.getProperties(),
+      }));
+  }
+
+  updateSize() {
+    this.kmap?.updateSize?.() || this.kmap?.map?.updateSize?.();
+  }
+
+  destroy() {
+    if (this.clickKey) {
+      unByKey(this.clickKey);
+      this.clickKey = null;
+    }
+    if (this.blueRegionLayer?.source) {
+      this.blueRegionLayer.source.clear();
+    }
+    this.kmap?.destroy?.();
+    this.kmap = null;
+  }
+}

+ 25 - 1
src/views/device/index.vue

@@ -142,6 +142,12 @@
     :data="editingRow"
     @submit="handleDialogSubmit"
   />
+
+  <DeviceDetailDialog
+    v-model="detailVisible"
+    :data="detailRow"
+    @edit="handleDetailEdit"
+  />
 </template>
 
 <script setup>
@@ -149,6 +155,7 @@ import { computed, ref } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
 import { Location } from "@element-plus/icons-vue";
 import DeviceFormDialog from "./DeviceFormDialog.vue";
+import DeviceDetailDialog from "./DeviceDetailDialog.vue";
 
 const tabs = ref([
   { key: "all", label: "全部设备", count: 158 },
@@ -176,6 +183,7 @@ const buildMockRows = (count = 101) => {
       type: "水肥一体",
       categories: ["类别一", "类别二", "类别三", "类别四", "类别五", "类别六"],
       location: "位置位置位置位置",
+      areaLabel: "一区,二区",
       ownerName: "张扬",
       ownerPhone: "19871513658",
       serviceRecords: [
@@ -237,6 +245,8 @@ const handleSelectionChange = (rows) => {
 const dialogVisible = ref(false);
 const dialogMode = ref("add");
 const editingRow = ref(null);
+const detailVisible = ref(false);
+const detailRow = ref(null);
 
 const handleAdd = () => {
   dialogMode.value = "add";
@@ -250,6 +260,17 @@ const handleEdit = (row) => {
   dialogVisible.value = true;
 };
 
+const handleDetail = (row) => {
+  detailRow.value = { ...row };
+  detailVisible.value = true;
+};
+
+const handleDetailEdit = () => {
+  if (!detailRow.value) return;
+  detailVisible.value = false;
+  handleEdit(detailRow.value);
+};
+
 const handleDialogSubmit = ({ mode, data }) => {
   if (mode === "edit" && data.id != null) {
     const idx = allList.value.findIndex((item) => item.id === data.id);
@@ -258,6 +279,10 @@ const handleDialogSubmit = ({ mode, data }) => {
         ...allList.value[idx],
         ...data,
       };
+      // 同步详情缓存
+      if (detailRow.value?.id === data.id) {
+        detailRow.value = { ...allList.value[idx] };
+      }
     }
     return;
   }
@@ -274,7 +299,6 @@ const handleDialogSubmit = ({ mode, data }) => {
 
 const handleViewLocation = () => ElMessage.info("查看存放点位");
 const handleViewArea = () => ElMessage.info("查看执行区域");
-const handleDetail = () => ElMessage.info("设备详情");
 
 const handleDelete = (row) => {
   ElMessageBox.confirm(`确认删除设备「${row.name}」吗?`, "提示", {

+ 171 - 0
src/views/device/locationSelectMap.js

@@ -0,0 +1,171 @@
+import * as KMap from "@/utils/ol-map/KMap";
+import config from "@/api/config";
+import { wktCastGeom, newAreaFeature } from "@/common/ol_common";
+import { unByKey } from "ol/Observable";
+import Feature from "ol/Feature";
+import { Point } from "ol/geom";
+import Style from "ol/style/Style";
+import Icon from "ol/style/Icon";
+import mapPointImg from "@/assets/page/map-point.png";
+
+/** 与 workDetail/areaMap 默认中心一致 */
+const DEFAULT_LOCATION = "POINT(113.61448114737868 23.585550924763083)";
+
+/**
+ * 存放点位选择地图
+ * - 展示蓝区范围(不可点选)
+ * - 地图中心默认点位图标,点击地图移动点位
+ */
+export default class LocationSelectMap {
+  constructor() {
+    const vectorStyle = new KMap.VectorStyle();
+    this.vectorStyle = vectorStyle;
+    this.kmap = null;
+    this.clickKey = null;
+    this.pointFeature = null;
+
+    this.blueRegionLayer = new KMap.VectorLayer("blueRegionLayer", 99999, {
+      minZoom: 1,
+      maxZoom: 22,
+      style: () =>
+        vectorStyle.getPolygonStyle(
+          "rgba(0, 0, 0, 0.45)",
+          "rgba(255, 212, 137, 0.85)",
+          1.5
+        ),
+    });
+
+    this.pointLayer = new KMap.VectorLayer("locationPointLayer", 100000, {
+      minZoom: 1,
+      maxZoom: 22,
+      style: new Style({
+        image: new Icon({
+          src: mapPointImg,
+          scale: 0.55,
+          anchor: [0.5, 1],
+          anchorXUnits: "fraction",
+          anchorYUnits: "fraction",
+        }),
+      }),
+    });
+  }
+
+  /**
+   * @param {HTMLElement} target
+   * @param {string} location WKT,优先用于点位初始位置
+   */
+  init(target, location = DEFAULT_LOCATION) {
+    this.destroy();
+
+    const level = 16;
+    const coordinate = wktCastGeom(location).getFirstCoordinate();
+
+    this.kmap = new KMap.Map(
+      target,
+      level,
+      coordinate[0],
+      coordinate[1],
+      null,
+      1,
+      22,
+      "vec",
+      true,
+      true
+    );
+
+    this.kmap.addLayer(this.blueRegionLayer.layer);
+    this.kmap.addLayer(this.pointLayer.layer);
+
+    const xyz = config.base_img_url + "map/lby/{z}/{x}/{y}.png";
+    this.kmap.addXYZLayer(xyz, { minZoom: 8, maxZoom: 22 }, 2);
+
+    this.setPoint(coordinate);
+    this.addMapSingleClick();
+    setTimeout(() => this.updateSize(), 80);
+  }
+
+  addMapSingleClick() {
+    this.clickKey = this.kmap.on("singleclick", (evt) => {
+      this.setPoint(evt.coordinate);
+    });
+  }
+
+  /** @param {[number, number]} coordinate [lng, lat] */
+  setPoint(coordinate) {
+    if (!this.pointLayer?.source || !coordinate) return;
+
+    if (this.pointFeature) {
+      this.pointFeature.getGeometry().setCoordinates(coordinate);
+      return;
+    }
+
+    this.pointFeature = new Feature({
+      geometry: new Point(coordinate),
+    });
+    this.pointFeature.set("nodeType", "location");
+    this.pointLayer.addFeature(this.pointFeature);
+  }
+
+  /**
+   * 仅展示蓝区,不可点击选择
+   * @param {Array} list
+   */
+  setRegions(list = []) {
+    if (!this.blueRegionLayer?.source) return;
+    this.blueRegionLayer.source.clear();
+
+    for (const item of list) {
+      const wkt = item.geom || item.wkt;
+      if (!wkt) continue;
+      const id = item.blueZoneCode ?? item.id;
+      const feature = newAreaFeature({
+        ...item,
+        geom: wkt,
+        id,
+        name: item.name || item.regionName || item.blueZoneName || String(id),
+      });
+      this.blueRegionLayer.addFeature(feature);
+    }
+
+    this.fitToRegions();
+  }
+
+  fitToRegions() {
+    const extent = this.blueRegionLayer?.source?.getExtent?.();
+    if (!extent || !isFinite(extent[0]) || !this.kmap) return;
+    this.kmap.fit(extent, { padding: [40, 40, 40, 40], duration: 300, maxZoom: 18 });
+  }
+
+  /** 将点位放到当前地图中心 */
+  setPointToCenter() {
+    const center = this.kmap?.view?.getCenter?.();
+    if (center) this.setPoint(center);
+  }
+
+  getPoint() {
+    if (!this.pointFeature) return null;
+    const [lng, lat] = this.pointFeature.getGeometry().getCoordinates();
+    return {
+      lng,
+      lat,
+      wkt: `POINT(${lng} ${lat})`,
+      label: `POINT(${Number(lng).toFixed(6)} ${Number(lat).toFixed(6)})`,
+    };
+  }
+
+  updateSize() {
+    this.kmap?.updateSize?.() || this.kmap?.map?.updateSize?.();
+  }
+
+  destroy() {
+    if (this.clickKey) {
+      unByKey(this.clickKey);
+      this.clickKey = null;
+    }
+    this.blueRegionLayer?.source?.clear?.();
+    this.pointLayer?.source?.clear?.();
+    this.pointFeature = null;
+    this.kmap?.destroy?.();
+    this.kmap = null;
+  }
+}