index.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. <template>
  2. <div class="base-container no-events">
  3. <fnHeader showDate :autoGo="true" hideSwitch></fnHeader>
  4. <div class="content">
  5. <div class="warning-l left">
  6. <div class="warning-top yes-events">
  7. <div class="btn-common">四川省-简阳市-平泉街道</div>
  8. </div>
  9. </div>
  10. <!-- 顶部基础 tabs -->
  11. <div class="base-tabs yes-events">
  12. <div v-for="tab in baseTabs" :key="tab" class="tab-item" :class="{ active: activeBaseTab === tab }"
  13. @click="handleBaseTabClick(tab)">
  14. {{ tab }}
  15. </div>
  16. </div>
  17. <div class="btn-common smart-farm-btn yes-events" @click="handleSmartFarmClick">智慧农场</div>
  18. <!-- 地图图例 -->
  19. <!-- <map-legend></map-legend> -->
  20. <land-use-legend @change="handleLegendChange"></land-use-legend>
  21. <div class="show-point yes-events" v-show="showPoint" @click="handleShowPointClick"></div>
  22. </div>
  23. </div>
  24. <div ref="mapRef" class="bottom-map"></div>
  25. <track-dialog></track-dialog>
  26. </template>
  27. <script setup>
  28. import "./map/mockFarmLayer";
  29. import StaticMapLayers from "@/components/static_map_change/Layers.js";
  30. import StaticMapPointLayers from "@/components/static_map_change/pointLayer.js"
  31. import { onMounted, onUnmounted, ref, nextTick } from "vue";
  32. import fnHeader from "@/components/fnHeader.vue";
  33. import landUseLegend from "./components/landUseLegend.vue";
  34. import WarningMap from "./warningMap";
  35. import trackDialog from "./components/trackDialog.vue";
  36. import AlarmLayer from "./map/alarmLayer";
  37. import DistributionLayer from "./map/distributionLayer";
  38. import BoundaryLayer from "./map/boundaryLayer";
  39. import WaterLayer from "./map/waterLayer";
  40. import eventBus from "@/api/eventBus";
  41. import { useStore } from "vuex";
  42. import { useRouter, useRoute } from "vue-router";
  43. let store = useStore();
  44. const router = useRouter();
  45. const route = useRoute();
  46. let warningMap = new WarningMap();
  47. let alarmLayer = null;
  48. let staticMapLayers = null;
  49. let distributionLayer = null;
  50. let staticMapPointLayers = null;
  51. let boundaryLayer = null;
  52. let waterLayer = null;
  53. const mapRef = ref(null);
  54. const treeRef = ref(null);
  55. const treeActionData = ref([]);
  56. // 保存原始数据,用于恢复
  57. const originalTreeData = ref([]);
  58. // 物候期分布下,当前激活的"二级"节点(只允许一个)
  59. const activePhenologySecondId = ref(null);
  60. // 当前选中的年份和季度
  61. const currentYear = ref(2025);
  62. const currentQuarter = ref(1);
  63. const isLandRecognition = ref(false);
  64. // 顶部基础 tabs
  65. const baseTabs = ["物候期分布", "长势等级", "水利", "灌渠与泵站", "资源", "导出报告"];
  66. const activeBaseTab = ref("物候期分布");
  67. const warningLayers = ref({});
  68. const showPoint = ref(false)
  69. const handleBaseTabClick = (tab) => {
  70. activeBaseTab.value = tab;
  71. showPoint.value = false
  72. staticMapPointLayers.hidePoint()
  73. staticMapLayers.hideAll()
  74. // 水利图层隐藏
  75. waterLayer && waterLayer.toggleLayer(false)
  76. waterLayer && waterLayer.toggleCanalLayer(false)
  77. if (tab === "资源") {
  78. staticMapPointLayers.showPoint()
  79. }else if (tab === "灌渠与泵站") {
  80. showPoint.value = true
  81. waterLayer && waterLayer.toggleCanalLayer(true)
  82. }else if (tab === "长势等级") {
  83. staticMapLayers.showSingle("Dongguan长势", false);
  84. }else if (tab === "物候期分布") {
  85. staticMapLayers.showSingle("Dongguan物候期", false);
  86. }else if (tab === "水利") {
  87. waterLayer && waterLayer.toggleLayer(true)
  88. }
  89. };
  90. const handleSmartFarmClick = () => {
  91. window.open("https://feiniao-pc-gly.feiniaotech.com/#/login");
  92. };
  93. const handleShowPointClick = () => {
  94. eventBus.emit("chat:showTrackDialog")
  95. }
  96. onMounted(async () => {
  97. // 使用 nextTick 确保 DOM 已经渲染完成,地图容器有正确的尺寸
  98. await nextTick();
  99. warningMap.initMap(store.getters.userinfo.location, mapRef.value);
  100. // 地图初始化后,更新地图尺寸以确保正确渲染
  101. if (warningMap.kmap && warningMap.kmap.map) {
  102. // 使用 setTimeout 确保地图容器尺寸已计算
  103. setTimeout(() => {
  104. warningMap.kmap.map.updateSize();
  105. }, 0);
  106. }
  107. alarmLayer = new AlarmLayer(warningMap.kmap);
  108. staticMapLayers = new StaticMapLayers(warningMap.kmap);
  109. staticMapPointLayers = new StaticMapPointLayers(warningMap.kmap);
  110. distributionLayer = new DistributionLayer(warningMap.kmap);
  111. boundaryLayer = new BoundaryLayer(warningMap.kmap);
  112. waterLayer = new WaterLayer(warningMap.kmap);
  113. await getSpeciesListData();
  114. getWaterData();
  115. getWaterCanalData();
  116. getDistributionData();
  117. // 数据加载完成后,再次更新地图尺寸以确保正确渲染
  118. if (warningMap.kmap && warningMap.kmap.map) {
  119. setTimeout(() => {
  120. warningMap.kmap.map.updateSize();
  121. }, 100);
  122. }
  123. // 胜华村的村界
  124. getVillageBoundary()
  125. eventBus.emit("warningMap:init", warningMap.kmap);
  126. handleBaseTabClick("物候期分布")
  127. // 图例数据
  128. eventBus.on("alarmList:warningLayers", (data) => {
  129. warningLayers.value = data;
  130. });
  131. // 窗口大小改变时更新地图尺寸
  132. const handleResize = () => {
  133. if (warningMap.kmap && warningMap.kmap.map) {
  134. warningMap.kmap.map.updateSize();
  135. }
  136. };
  137. window.addEventListener("resize", handleResize);
  138. // 在组件卸载时清理
  139. onUnmounted(() => {
  140. window.removeEventListener("resize", handleResize);
  141. });
  142. });
  143. // 加载东莞市行政区边界(使用远程 GeoJSON 数据)
  144. const getVillageBoundary = async () => {
  145. const url =
  146. "https://birdseye-img.sysuimars.com/geojson/mlxy/%E4%B8%9C%E8%8E%9E%E8%A1%8C%E6%94%BF%E5%8C%BA.geojson";
  147. try {
  148. const response = await fetch(url);
  149. if (!response.ok) {
  150. console.error("获取行政区 GeoJSON 失败:", response.statusText);
  151. return;
  152. }
  153. const geojson = await response.json();
  154. if (!geojson || !Array.isArray(geojson.features)) {
  155. console.error("行政区 GeoJSON 格式不正确:", geojson);
  156. return;
  157. }
  158. // 将 GeoJSON MultiPolygon 转成 boundaryLayer 需要的 WKT 格式数据
  159. const geoJsonMultiPolygonToWkt = (geometry) => {
  160. if (!geometry || geometry.type !== "MultiPolygon" || !Array.isArray(geometry.coordinates)) {
  161. return "";
  162. }
  163. const polygons = geometry.coordinates
  164. .map((polygon) => {
  165. // polygon: [ [ [x, y], ... ] ] => 每个 polygon 可能包含多个 ring
  166. const rings = polygon
  167. .map((ring) => {
  168. const coords = ring.map((coord) => `${coord[0]} ${coord[1]}`).join(", ");
  169. return `(${coords})`;
  170. })
  171. .join(", ");
  172. return `(${rings})`;
  173. })
  174. .join(", ");
  175. return `MULTIPOLYGON (${polygons})`;
  176. };
  177. const villageBoundary = geojson.features
  178. .map((f, index) => {
  179. const wkt = geoJsonMultiPolygonToWkt(f.geometry);
  180. if (!wkt) return null;
  181. const props = f.properties || {};
  182. return {
  183. id: props.code || props["区划码"] || index,
  184. name: props["地名"] || props.ENG_NAME || "行政区边界",
  185. geom: wkt,
  186. };
  187. })
  188. .filter((item) => item);
  189. if (!villageBoundary.length) {
  190. console.warn("未能从 GeoJSON 中解析出有效的行政区边界");
  191. return;
  192. }
  193. boundaryLayer && boundaryLayer.initData(villageBoundary);
  194. } catch (error) {
  195. console.error("加载行政区 GeoJSON 出错:", error);
  196. }
  197. };
  198. const getWaterData = async () => {
  199. const { data } = await VE_API.layer.waterList();
  200. waterLayer.initData(data);
  201. const { data: riverData } = await VE_API.layer.riverList();
  202. waterLayer.initRiver(riverData);
  203. };
  204. // 获取水渠数据
  205. const getWaterCanalData = async () => {
  206. const { data } = await VE_API.warning.fetchLandCanalList();
  207. waterLayer.initCanal(data);
  208. };
  209. sessionStorage.removeItem("farmId");
  210. onUnmounted(() => {
  211. eventBus.off("alarmList:changeMapLayer");
  212. eventBus.off("chartList:updateMap");
  213. // 时间轴
  214. eventBus.off("weatherTime:changeTime");
  215. });
  216. const getSpeciesListData = async () => {
  217. const res = await VE_API.species.speciesList();
  218. treeActionData.value = res.data;
  219. // 保存原始数据副本(深拷贝)
  220. originalTreeData.value = JSON.parse(JSON.stringify(res.data));
  221. };
  222. const getDistributionData = async (statType = null) => {
  223. const { data } = await VE_API.agri_land_crop.queryDistribution({ statType: statType || 0 });
  224. // 把点位图层去掉:不返回 centerPoint,只保留地块相关信息
  225. const list = Array.isArray(data)
  226. ? data.map((item) => {
  227. const { centerPoint, ...rest } = item || {};
  228. return rest;
  229. })
  230. : [];
  231. distributionLayer.initData(list);
  232. };
  233. const fetchFarmList = (phenologyIds) => {
  234. const params = {
  235. year: currentYear.value,
  236. quarter: currentQuarter.value,
  237. phenologyIds: phenologyIds || [],
  238. };
  239. return new Promise((resolve, reject) => {
  240. VE_API.warning
  241. .fetchFarmList(params)
  242. .then((res) => {
  243. if (res.code === 0 && res.data && res.data.length > 0) {
  244. // 将接口数据转换为地图需要的格式
  245. const cropData = res.data.map((item) => {
  246. // 组合作物名称和物候期名称作为 label
  247. const label = item.phenologyName
  248. ? `${item.speciesName}-${item.phenologyName}`
  249. : item.speciesName;
  250. return {
  251. ...item,
  252. label: label,
  253. color: item.speciesColor || "#2199F8",
  254. centerPoint: item.point, // 使用 point 作为 centerPoint
  255. };
  256. });
  257. // 渲染作物数据到地图
  258. distributionLayer.initData(cropData, "label");
  259. resolve(cropData);
  260. } else {
  261. // 接口返回空数据时,清空地图
  262. distributionLayer.initData([]);
  263. resolve([]);
  264. }
  265. })
  266. .catch((error) => {
  267. // 错误时也清空地图
  268. distributionLayer.initData([]);
  269. reject(error);
  270. });
  271. });
  272. };
  273. // 根据节点 id 在当前树数据中计算其层级(1/2/3)及所属的二级节点 id
  274. const getNodeLevelInfo = (id) => {
  275. const roots = treeActionData.value || [];
  276. for (const root of roots) {
  277. if (root.id === id) {
  278. return { level: 1, secondId: null };
  279. }
  280. const rootChildren = root.items || root.children || [];
  281. for (const second of rootChildren) {
  282. if (second.id === id) {
  283. return { level: 2, secondId: second.id };
  284. }
  285. const secondChildren = second.items || second.children || [];
  286. for (const third of secondChildren) {
  287. if (third.id === id) {
  288. return { level: 3, secondId: second.id };
  289. }
  290. }
  291. }
  292. }
  293. return { level: 0, secondId: null };
  294. };
  295. const getTreeChecks = async (nodeData, data) => {
  296. const { checkedNodes } = data;
  297. let finalCheckedNodes = checkedNodes;
  298. // 物候期分布:限制"二级只能选一个,三级不限个数"
  299. if (
  300. (activeBaseTab.value === "物候期分布" ||
  301. activeBaseTab.value === "预警分布" ||
  302. activeBaseTab.value === "农场分布") &&
  303. treeRef.value
  304. ) {
  305. const tree = treeRef.value;
  306. const { level, secondId } = getNodeLevelInfo(nodeData.id);
  307. if (level === 2 || level === 3) {
  308. const currentSecondId = secondId;
  309. // 判断当前这个二级分支下,是否还有被选中的节点(包含二级自己或其子级)
  310. const hasAnyCheckedInCurrentSecond = checkedNodes.some((n) => {
  311. const info = getNodeLevelInfo(n.id);
  312. return info.secondId === currentSecondId || (info.level === 2 && n.id === currentSecondId);
  313. });
  314. if (hasAnyCheckedInCurrentSecond) {
  315. // 仍有节点被选中 → 保证只有当前这个二级分支被选中,其它分支全部取消
  316. activePhenologySecondId.value = currentSecondId;
  317. const roots = treeActionData.value || [];
  318. roots.forEach((root) => {
  319. const rootChildren = root.items || root.children || [];
  320. rootChildren.forEach((second) => {
  321. if (second.id !== currentSecondId) {
  322. // 取消其它二级及其所有子级勾选
  323. tree.setChecked(second.id, false, true);
  324. }
  325. // 对于当前二级节点,不手动设置其选中状态
  326. // 让 Element Plus 根据子节点的选中状态自动计算半选中状态
  327. // 这样当部分三级节点被选中时,二级节点会自动显示半选中状态
  328. });
  329. });
  330. } else {
  331. // 当前二级分支已经被全部取消勾选 → 清空激活记录,允许"全部不选"
  332. activePhenologySecondId.value = null;
  333. }
  334. }
  335. // 对树进行了 setChecked 操作后,重新从树组件拿一次最新的选中节点列表
  336. // 这里只需要最后一层(叶子节点 / 有 wktArr 的节点),不用父级节点
  337. const allCheckedNodes = treeRef.value.getCheckedNodes(false, true);
  338. finalCheckedNodes = allCheckedNodes.filter((n) => !n.children || n.children.length === 0 || n.wktArr);
  339. }
  340. // 任意 tab 下,最终都用当前选中的节点驱动地图渲染
  341. // 提取最后一级节点的 id 到数组(没有子节点的叶子节点)
  342. const field = activeBaseTab.value === "物候期分布" || activeBaseTab.value === "农场分布" ? "originalId" : "id";
  343. const lastLevelIds = finalCheckedNodes
  344. .filter((n) => (!n.items || n.items.length === 0) && (!n.children || n.children.length === 0))
  345. .map((n) => n[field]);
  346. if (lastLevelIds && lastLevelIds.length === 0) {
  347. distributionLayer.initData([]);
  348. return;
  349. }
  350. if (activeBaseTab.value === "物候期分布") {
  351. const phenologyData = await getDistributionData(null, lastLevelIds);
  352. distributionLayer.initData(phenologyData, "phenologyName");
  353. return;
  354. }
  355. if (activeBaseTab.value === "农场分布") {
  356. await fetchFarmList(lastLevelIds);
  357. return;
  358. }
  359. // 并发请求所有数据,等待所有 Promise 完成
  360. const promises = lastLevelIds.map((id) => {
  361. const node = finalCheckedNodes.find((n) => n.id === id);
  362. if (node) {
  363. return getDistributionData(node.id);
  364. }
  365. return Promise.resolve([]);
  366. });
  367. // 等待所有请求完成,并将结果扁平化
  368. const results = await Promise.all(promises);
  369. const finalMapData = results.flat();
  370. distributionLayer.initData(finalMapData);
  371. };
  372. // 处理图例变化
  373. const handleLegendChange = (data) => {
  374. console.log("图例变化:", data);
  375. // 通过 eventBus 将选中的类别信息传递给饼图组件
  376. if (data.checked) {
  377. eventBus.emit("landUseLegend:change", {
  378. category: data.category,
  379. nonGrain: data.nonGrain,
  380. children: data.children,
  381. });
  382. }
  383. };
  384. </script>
  385. <style lang="scss" scoped>
  386. .base-container {
  387. width: 100%;
  388. height: 100vh;
  389. color: #fff;
  390. position: absolute;
  391. box-sizing: border-box;
  392. z-index: 1;
  393. overflow: hidden;
  394. .content {
  395. width: 100%;
  396. height: calc(100% - 74px - 48px);
  397. padding: 16px 20px 0 27px;
  398. display: flex;
  399. justify-content: space-between;
  400. box-sizing: border-box;
  401. position: relative;
  402. .show-point{
  403. position: absolute;
  404. top: calc(50% - 250px);
  405. left: calc(50% - 250px);
  406. width: 500px;
  407. height: 500px;
  408. // background: red;
  409. z-index: 2;
  410. }
  411. .left,
  412. .right {
  413. width: calc(376px + 54px);
  414. height: 100%;
  415. box-sizing: border-box;
  416. // display: flex;
  417. }
  418. .right {
  419. // width: 395px;
  420. width: 376px;
  421. overflow: auto;
  422. position: relative;
  423. .list {
  424. width: 100%;
  425. height: 100%;
  426. }
  427. }
  428. .chart-wrap {
  429. padding: 8px;
  430. background: #101010;
  431. border: 1px solid #444444;
  432. }
  433. .btn-common {
  434. color: #f7be5a;
  435. font-size: 20px;
  436. font-family: "PangMenZhengDao";
  437. margin-right: 15px;
  438. border: 2px solid rgba(255, 212, 137, 0.3);
  439. border-radius: 4px;
  440. padding: 8px 14px 11px;
  441. background: rgba(29, 29, 29, 0.54);
  442. cursor: pointer;
  443. }
  444. .smart-farm-btn{
  445. position: fixed;
  446. top: 35px;
  447. right: 200px;
  448. padding: 10px 45px 13px;
  449. }
  450. .warning-top {
  451. display: flex;
  452. align-items: center;
  453. width: max-content;
  454. }
  455. .base-tabs {
  456. display: flex;
  457. position: fixed;
  458. top: 35px;
  459. left: 390px;
  460. .tab-item {
  461. border-radius: 6px;
  462. padding: 6px 15px 8px;
  463. margin-right: 12px;
  464. text-align: center;
  465. font-size: 20px;
  466. font-family: "PangMenZhengDao";
  467. color: #ffffff;
  468. background: rgba(37, 50, 57, 0.6);
  469. cursor: pointer;
  470. border: 2px solid transparent;
  471. &.active {
  472. background: rgba(115, 74, 2, 0.16);
  473. color: #FFD489;
  474. border-color: #FFD489;
  475. }
  476. }
  477. }
  478. }
  479. }
  480. .bottom-map {
  481. width: 100%;
  482. height: 100vh;
  483. position: absolute;
  484. z-index: 0;
  485. }
  486. </style>
  487. <style lang="less">
  488. .ol-scale-line {
  489. left: auto;
  490. right: 435px;
  491. bottom: 13px;
  492. .ol-scale-line-inner {
  493. max-width: 80px;
  494. width: 80px !important;
  495. color: #fff;
  496. border-color: #fff;
  497. }
  498. }
  499. .focus-farm-select {
  500. &.el-popper.is-light {
  501. background: #232323;
  502. border-color: rgba(255, 212, 137, 0.3);
  503. box-shadow: 0px 0px 12px rgba(255, 212, 137, 0.3);
  504. .el-select-dropdown__item {
  505. background: none;
  506. color: rgba(255, 212, 137, 0.6);
  507. }
  508. .el-select-dropdown__item.is-selected {
  509. background: rgba(255, 212, 137, 0.2);
  510. color: #ffd489;
  511. }
  512. }
  513. &.el-popper.is-light .el-popper__arrow:before {
  514. background: #232323;
  515. border-color: rgba(255, 212, 137, 0.3);
  516. }
  517. }
  518. .ol-popup-warning {
  519. position: relative;
  520. width: 295px;
  521. background: rgb(35, 35, 35, 0.86);
  522. color: #fff;
  523. font-size: 16px;
  524. border-radius: 4px;
  525. .warning-info-title {
  526. display: flex;
  527. padding: 6px 10px;
  528. background: rgba(255, 255, 255, 0.05);
  529. font-size: 18px;
  530. border-radius: 4px 4px 0 0;
  531. .icon {
  532. padding-right: 6px;
  533. }
  534. .close {
  535. position: absolute;
  536. right: 12px;
  537. top: 4px;
  538. }
  539. }
  540. .info-content {
  541. padding: 16px 20px 40px 20px;
  542. line-height: 26px;
  543. text-indent: 2em;
  544. }
  545. }
  546. .area-cascader {
  547. &.el-popper.is-light {
  548. background: #232323;
  549. border-color: rgba(255, 212, 137, 0.3);
  550. box-shadow: 0px 0px 12px rgba(255, 212, 137, 0.3);
  551. .el-cascader-menu {
  552. color: rgba(255, 212, 137, 0.6);
  553. border-color: rgba(255, 212, 137, 0.3);
  554. }
  555. .el-cascader-node.in-active-path,
  556. .el-cascader-node.is-active,
  557. .el-cascader-node.is-selectable.in-checked-path {
  558. color: #f7be5a;
  559. background: transparent;
  560. }
  561. .el-radio__input.is-checked .el-radio__inner {
  562. background: #f7be5a;
  563. border-color: #f7be5a;
  564. }
  565. .el-cascader-node:not(.is-disabled):hover,
  566. .el-cascader-node:not(.is-disabled):focus,
  567. .el-cascader-node:not(.is-disabled):hover {
  568. background: rgba(255, 212, 137, 0.2);
  569. }
  570. }
  571. .el-radio__inner {
  572. background-color: rgba(255, 212, 137, 0.3);
  573. border-color: rgba(255, 212, 137, 0.6);
  574. }
  575. .el-radio__inner::after {
  576. background: #000;
  577. }
  578. &.el-popper.is-light .el-popper__arrow:before {
  579. background: #232323;
  580. border-color: rgba(255, 212, 137, 0.3);
  581. }
  582. }
  583. </style>