index.vue 21 KB

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