addZone.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. <template>
  2. <div class="add-zone-page">
  3. <custom-header :name="isFarmDrawMode ? t('agriFile.drawFarmArea') : t('agriFile.addZone')" />
  4. <div class="add-zone-content">
  5. <div class="map-container" ref="mapContainer"></div>
  6. <div class="search-bar">
  7. <location-search class="search-bar__search" :user-location="userLocation"
  8. @change="handleLocationChange" />
  9. </div>
  10. <div class="map-tip normal-text" v-show="isFarmDrawMode">请勾画 <span class="map-tip__highlight">{{ getFarmType }}</span>
  11. 的种植区域</div>
  12. <div class="map-tip" v-show="!isFarmDrawMode">{{ t("agriFile.selectZoneHint") }}</div>
  13. <div class="locate-btn" @click="handleLocate">
  14. <img class="locate-btn__icon" src="@/assets/img/map/map-icon.png" alt="" />
  15. </div>
  16. </div>
  17. <div class="custom-bottom-fixed-btns">
  18. <div class="bottom-btn secondary-btn" @click="handleClearDraw">{{ t("agriFile.cancelSelect") }}</div>
  19. <div class="bottom-btn primary-btn" @click="handleConfirmDraw">{{ t("agriFile.confirmArea") }}</div>
  20. </div>
  21. <RegionNamePopup v-model:show="showRegionNamePopup" @confirm="handleFarmNameConfirm" />
  22. <add-zone-info-popup
  23. v-model:show="showZoneInfoPopup"
  24. :polygon="drawnPolygon"
  25. @confirm="handleZoneInfoConfirm"
  26. />
  27. <tip-popup v-model:show="showExecuteSuccessPopup" type="executeSuccess" text="分区/相册已创建成功" buttonText="完成" @confirm="handleComplete">
  28. <!-- <template #footer>
  29. <div class="bottom-btn-container">
  30. <div class="bottom-btn secondary-btn" @click="handleComplete">完成</div>
  31. </div>
  32. </template> -->
  33. </tip-popup>
  34. <tip-popup
  35. v-model:show="showDrawSuccessPopup"
  36. type="success"
  37. text="区域已勾画完成"
  38. hideBtn
  39. @closedPopup="handleDrawSuccessClosed"
  40. />
  41. </div>
  42. </template>
  43. <script setup>
  44. import { computed, nextTick, onActivated, onDeactivated, ref } from "vue";
  45. import { useRoute, useRouter } from "vue-router";
  46. import { useStore } from "vuex";
  47. import { ElMessage } from "element-plus";
  48. import customHeader from "@/components/customHeader.vue";
  49. import locationSearch from "@/components/pageComponents/locationSearch.vue";
  50. import RegionNamePopup from "@/views/old_mini/recordDetails/components/RegionNamePopup.vue";
  51. import addZoneInfoPopup from "../components/addZoneInfoPopup.vue";
  52. import MapManage from "@/views/old_mini/recordDetails/map/mapManage.js";
  53. import { POINT_ICON } from "@/views/old_mini/entry_information/map/selectLocationMap.js";
  54. import * as util from "@/common/ol_common.js";
  55. import { useI18n } from "@/i18n";
  56. import tipPopup from "@/components/popup/tipPopup.vue";
  57. const { t } = useI18n();
  58. const route = useRoute();
  59. const router = useRouter();
  60. const isFarmDrawMode = computed(() => route.query.type === "farm");
  61. const isRegionDrawMode = computed(() => route.query.type === "region");
  62. const isSuccessPopupParam = computed(() => route.query.isSuccessPopup === "1");
  63. const showDrawSuccessPopup = ref(false);
  64. const targetUrl = computed(() => route.query.targetUrl);
  65. const isAbnormal = computed(() => {
  66. const val = route.query.isAbnormal;
  67. return val === true || val === "true";
  68. });
  69. const store = useStore();
  70. const mapContainer = ref(null);
  71. const mapManage = new MapManage();
  72. const showRegionNamePopup = ref(false);
  73. const showZoneInfoPopup = ref(false);
  74. /** 确认勾画后传给信息弹窗提交的范围 WKT */
  75. const drawnPolygon = ref("");
  76. const DEFAULT_MAP_LOCATION = "POINT(113.6142086995688 23.585836479509055)";
  77. const ZONE_STYLE = {
  78. fill: "rgba(33, 153, 248, 0.35)",
  79. fillSelected: "rgba(33, 153, 248, 0.45)",
  80. stroke: "#2199F8",
  81. };
  82. /** 已有分区种植范围样式 */
  83. const EXISTING_ZONE_STYLE = {
  84. fill: "rgba(0, 0, 0, 0.38)",
  85. stroke: "rgba(200, 200, 200, 0.55)",
  86. };
  87. const existingVarietyZones = ref([]);
  88. function mapExistingZoneItem(zone) {
  89. const polygon = typeof zone?.polygon === "string" ? zone.polygon.trim() : "";
  90. if (!polygon) return null;
  91. return {
  92. id: zone.zone_id,
  93. name: zone.zone_name || "",
  94. fill: EXISTING_ZONE_STYLE.fill,
  95. stroke: EXISTING_ZONE_STYLE.stroke,
  96. polygon,
  97. };
  98. }
  99. async function fetchExistingVarietyZones() {
  100. const farm = getFarmData();
  101. const farmId = farm.farm_id ?? farm.id;
  102. if (farmId == null || farmId === "") {
  103. existingVarietyZones.value = [];
  104. return;
  105. }
  106. try {
  107. const res = await VE_API.questionnaire.getZones({ farm_id: farmId });
  108. const zones = Array.isArray(res?.data?.zones)
  109. ? res.data.zones
  110. : Array.isArray(res?.data)
  111. ? res.data
  112. : [];
  113. existingVarietyZones.value = zones.map(mapExistingZoneItem).filter(Boolean);
  114. } catch (e) {
  115. console.warn("[addZone] getZones failed", e);
  116. existingVarietyZones.value = [];
  117. }
  118. }
  119. const userLocation = ref(
  120. store.state.home.miniUserLocation ||
  121. localStorage.getItem("MINI_USER_LOCATION") ||
  122. "113.61702297075017,23.584863449735067"
  123. );
  124. function getFarmData() {
  125. try {
  126. return JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
  127. } catch {
  128. return {};
  129. }
  130. }
  131. function isPointWkt(value) {
  132. return typeof value === "string" && /^POINT\s*\(/i.test(value.trim());
  133. }
  134. function wkbHexToPointWkt(hex) {
  135. const text = String(hex || "").trim();
  136. if (!/^[0-9a-fA-F]+$/.test(text) || text.length < 42) return null;
  137. const bytes = new Uint8Array(text.length / 2);
  138. for (let i = 0; i < bytes.length; i++) {
  139. bytes[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
  140. }
  141. const view = new DataView(bytes.buffer);
  142. const little = view.getUint8(0) === 1;
  143. let type = view.getUint32(1, little);
  144. const hasSrid = (type & 0x20000000) !== 0;
  145. type &= 0xff;
  146. if (type !== 1) return null;
  147. let offset = 5;
  148. if (hasSrid) offset += 4;
  149. if (offset + 16 > bytes.length) return null;
  150. const lng = view.getFloat64(offset, little);
  151. const lat = view.getFloat64(offset + 8, little);
  152. if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
  153. return `POINT(${lng} ${lat})`;
  154. }
  155. function toPointWkt(value) {
  156. if (!value || typeof value !== "string") return null;
  157. if (isPointWkt(value)) return value.trim();
  158. return wkbHexToPointWkt(value);
  159. }
  160. function getFarmMapLocation() {
  161. const farmData = getFarmData();
  162. const candidates = [farmData.wkt, farmData.geom_wkt, farmData.farm_location];
  163. for (const item of candidates) {
  164. const pointWkt = toPointWkt(item);
  165. if (pointWkt) return pointWkt;
  166. }
  167. return DEFAULT_MAP_LOCATION;
  168. }
  169. const getFarmType = computed(() => getFarmData().variety_name);
  170. function getDefaultMapLocation() {
  171. return (
  172. toPointWkt(localStorage.getItem("MINI_USER_LOCATION_POINT")) ||
  173. toPointWkt(store.state.home.miniUserLocationPoint) ||
  174. getFarmMapLocation()
  175. );
  176. }
  177. function getDefaultMapCoordinate() {
  178. return util.wktCastGeom(getDefaultMapLocation()).getFirstCoordinate();
  179. }
  180. function getFarmDrawCoordinate() {
  181. return util.wktCastGeom(getFarmMapLocation()).getFirstCoordinate();
  182. }
  183. function getMapLocation() {
  184. if (isFarmDrawMode.value || isRegionDrawMode.value) return getFarmMapLocation();
  185. return getDefaultMapLocation();
  186. }
  187. async function initMapView() {
  188. if (!mapContainer.value) return;
  189. mapManage.destroyMap();
  190. mapManage.initMap(getMapLocation(), mapContainer.value, {
  191. editable: true,
  192. zoneStyle: ZONE_STYLE,
  193. singleDraw: true,
  194. preventOverlapExisting: isRegionDrawMode.value,
  195. onDrawOverlap: () => {
  196. ElMessage.warning("新绘制范围不能与已有品种种植范围重叠");
  197. },
  198. onDrawLimit: () => {
  199. ElMessage.warning("只能勾画一个范围,请先取消后再重新勾画");
  200. },
  201. });
  202. mapManage.enableRegionDrawing();
  203. if (isFarmDrawMode.value) {
  204. mapManage.showCenterMarker(getFarmDrawCoordinate(), POINT_ICON.plant);
  205. }
  206. if (isRegionDrawMode.value) {
  207. await fetchExistingVarietyZones();
  208. mapManage.setExistingVarietyZones(existingVarietyZones.value);
  209. mapManage.setMapPosition(getFarmDrawCoordinate());
  210. }
  211. }
  212. const handleLocationChange = (payload) => {
  213. if (!payload?.coordinateArray) return;
  214. mapManage.setMapPosition(payload.coordinateArray);
  215. };
  216. const handleLocate = () => {
  217. if (isFarmDrawMode.value) {
  218. const coordinate = getFarmDrawCoordinate();
  219. mapManage.setMapPosition(coordinate);
  220. mapManage.showCenterMarker(coordinate, POINT_ICON.plant);
  221. return;
  222. }
  223. if (isRegionDrawMode.value) {
  224. mapManage.setMapPosition(getFarmDrawCoordinate());
  225. return;
  226. }
  227. mapManage.setMapPosition(getDefaultMapCoordinate());
  228. };
  229. const handleClearDraw = () => {
  230. // 只清当前勾画,保留已有品种种植范围
  231. mapManage.clearLayer();
  232. if (isRegionDrawMode.value) {
  233. mapManage.setExistingVarietyZones(existingVarietyZones.value);
  234. }
  235. };
  236. const handleConfirmDraw = () => {
  237. const payload = mapManage.getAreaGeometry();
  238. if (!payload.geometryArr.length) {
  239. ElMessage.warning(t("agriFile.pleaseDrawZone"));
  240. return;
  241. }
  242. if (isRegionDrawMode.value && mapManage.hasDrawnOverlapWithExisting()) {
  243. ElMessage.warning("新绘制范围不能与已有品种种植范围重叠");
  244. return;
  245. }
  246. drawnPolygon.value = toMultiPolygonWkt(mapManage.mergeGeometryWkts(payload.geometryArr));
  247. if (isFarmDrawMode.value) {
  248. showRegionNamePopup.value = true;
  249. return;
  250. }
  251. showZoneInfoPopup.value = true;
  252. };
  253. const leaveAfterConfirm = () => {
  254. if (targetUrl.value) {
  255. setTimeout(() => {
  256. router.push(targetUrl.value);
  257. }, 800);
  258. return;
  259. }
  260. setTimeout(() => {
  261. router.back();
  262. }, 800);
  263. };
  264. /** POLYGON → MULTIPOLYGON,与 update_zone_polygon 入参一致 */
  265. function toMultiPolygonWkt(wkt) {
  266. const text = String(wkt || "").trim();
  267. if (!text) return "";
  268. if (/^MULTIPOLYGON/i.test(text)) return text;
  269. if (/^POLYGON\s*\(/i.test(text)) {
  270. const body = text.replace(/^POLYGON\s*/i, "").trim();
  271. return `MULTIPOLYGON (${body})`;
  272. }
  273. return text;
  274. }
  275. function resolveSubmitFarmId() {
  276. const farm = getFarmData();
  277. return farm.farm_id ?? farm.id ?? "";
  278. }
  279. function resolveSubmitZoneId() {
  280. const farm = getFarmData();
  281. return route.query.trackId || farm.zone_id || farm.zoneId || "";
  282. }
  283. const submittingZoneRange = ref(false);
  284. /** 巡园要点 needDraw 进入:填名称后提交分区范围 */
  285. const handleFarmNameConfirm = async (regionName) => {
  286. if (submittingZoneRange.value) return;
  287. const payload = mapManage.getAreaGeometry();
  288. if (!payload.geometryArr.length) {
  289. ElMessage.warning(t("agriFile.pleaseDrawZone"));
  290. return;
  291. }
  292. const farmId = resolveSubmitFarmId();
  293. const zoneId = resolveSubmitZoneId();
  294. const polygon = toMultiPolygonWkt(mapManage.mergeGeometryWkts(payload.geometryArr));
  295. if (!farmId || !zoneId || !polygon) {
  296. ElMessage.warning("缺少农场或分区信息,请返回重试");
  297. return;
  298. }
  299. submittingZoneRange.value = true;
  300. try {
  301. const res = await VE_API.questionnaire.drawZoneRange({
  302. farm_id: farmId,
  303. zone_id: zoneId,
  304. zone_name: regionName,
  305. polygon,
  306. });
  307. if (res?.code !== 200) {
  308. ElMessage.error(res?.msg || "提交失败,请稍后再试");
  309. return;
  310. }
  311. } catch (e) {
  312. console.warn("[addZone] drawZoneRange failed", e);
  313. ElMessage.error("提交失败,请稍后再试");
  314. return;
  315. } finally {
  316. submittingZoneRange.value = false;
  317. }
  318. const geometryData = {
  319. zone_name: regionName,
  320. geometryArr: payload.geometryArr,
  321. mianji: payload.mianji,
  322. polygon,
  323. };
  324. sessionStorage.setItem("FARM_RANGE_GEOMETRY", JSON.stringify(geometryData));
  325. // console.log("isSuccessPopupParam", isSuccessPopupParam.value);
  326. console.log("isSuccessPopupParam", isSuccessPopupParam.value, route.query);
  327. if (isSuccessPopupParam.value) {
  328. showDrawSuccessPopup.value = true;
  329. return;
  330. }
  331. ElMessage.success(t("agriFile.drawFarmAreaSuccess"));
  332. if (!isAbnormal.value && targetUrl.value) {
  333. setTimeout(() => {
  334. const query = { showUpload: "1" };
  335. if (route.query.trackId) query.id = route.query.trackId;
  336. if (route.query.trackType) query.type = route.query.trackType;
  337. router.replace({ path: "/growth_track", query });
  338. }, 800);
  339. return;
  340. }
  341. leaveAfterConfirm();
  342. };
  343. const showExecuteSuccessPopup = ref(false);
  344. const handleZoneInfoConfirm = (formData) => {
  345. const payload = mapManage.getAreaGeometry();
  346. const geometryData = {
  347. ...formData,
  348. geometryArr: payload.geometryArr,
  349. mianji: payload.mianji,
  350. };
  351. sessionStorage.setItem("ADD_ZONE_GEOMETRY", JSON.stringify(geometryData));
  352. showExecuteSuccessPopup.value = true;
  353. };
  354. const handleDrawSuccessClosed = () => {
  355. showDrawSuccessPopup.value = false;
  356. leaveAfterConfirm();
  357. }
  358. const handleComplete = () => {
  359. showExecuteSuccessPopup.value = false;
  360. // 农事档案上传入口 needDraw 勾画:返回并继续打开上传弹窗
  361. if (isRegionDrawMode.value && route.query.continueUpload === "1") {
  362. sessionStorage.setItem(
  363. "AGRI_FILE_CONTINUE_UPLOAD",
  364. JSON.stringify({ zoneId: route.query.zoneId || "" })
  365. );
  366. router.back();
  367. return;
  368. }
  369. leaveAfterConfirm();
  370. }
  371. onActivated(() => {
  372. nextTick(() => initMapView());
  373. });
  374. onDeactivated(() => {
  375. mapManage.destroyMap();
  376. });
  377. </script>
  378. <style lang="scss" scoped>
  379. .add-zone-page {
  380. width: 100%;
  381. height: 100vh;
  382. overflow: hidden;
  383. background: #fff;
  384. display: flex;
  385. flex-direction: column;
  386. }
  387. .add-zone-content {
  388. position: relative;
  389. flex: 1;
  390. min-height: 0;
  391. overflow: hidden;
  392. .map-container {
  393. width: 100%;
  394. height: 100%;
  395. }
  396. .search-bar {
  397. position: absolute;
  398. top: 12px;
  399. left: 12px;
  400. right: 12px;
  401. z-index: 2;
  402. display: flex;
  403. align-items: center;
  404. height: 40px;
  405. padding: 0 4px 0 12px;
  406. border-radius: 20px;
  407. background: rgba(0, 0, 0, 0.45);
  408. box-sizing: border-box;
  409. &__search {
  410. flex: 1;
  411. min-width: 0;
  412. :deep(.el-select__wrapper) {
  413. background: transparent;
  414. box-shadow: none;
  415. border: none;
  416. min-height: 40px;
  417. padding-left: 0;
  418. }
  419. :deep(.el-select__placeholder),
  420. :deep(.el-select__input) {
  421. color: rgba(255, 255, 255, 0.7);
  422. }
  423. :deep(.el-icon) {
  424. color: rgba(255, 255, 255, 0.85);
  425. }
  426. }
  427. }
  428. .map-tip {
  429. position: absolute;
  430. top: 64px;
  431. left: 50%;
  432. z-index: 2;
  433. transform: translateX(-50%);
  434. padding: 8px 30px;
  435. border-radius: 25px;
  436. background: rgba(0, 0, 0, 0.5);
  437. color: #7CC5FF;
  438. font-size: 12px;
  439. &.normal-text {
  440. color: #fff;
  441. }
  442. &__highlight {
  443. color: #2199F8;
  444. }
  445. }
  446. .locate-btn {
  447. position: absolute;
  448. right: 12px;
  449. bottom: 24px;
  450. z-index: 2;
  451. display: flex;
  452. align-items: center;
  453. justify-content: center;
  454. width: 36px;
  455. height: 36px;
  456. border-radius: 8px;
  457. background: #fff;
  458. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  459. &__icon {
  460. width: 16px;
  461. height: 18px;
  462. }
  463. }
  464. }
  465. .custom-bottom-fixed-btns {
  466. position: relative;
  467. justify-content: space-between;
  468. .bottom-btn {
  469. padding: 10px 20px;
  470. border-radius: 25px;
  471. }
  472. .secondary-btn {
  473. color: #666;
  474. border: 1px solid rgba(153, 153, 153, 0.5);
  475. }
  476. .primary-btn {
  477. background: #2199f8;
  478. }
  479. }
  480. .bottom-btn-container {
  481. display: flex;
  482. justify-content: space-between;
  483. gap: 12px;
  484. width: 100%;
  485. .bottom-btn {
  486. padding: 0 25px;
  487. border-radius: 4px;
  488. font-size: 16px;
  489. height: 40px;
  490. line-height: 40px;
  491. box-sizing: border-box;
  492. font-weight: 500;
  493. text-align: center;
  494. &.secondary-btn {
  495. color: #B1B1B1;
  496. border: 1px solid #EAEAEA;
  497. }
  498. &.primary-btn {
  499. flex: 1;
  500. background: #2199F8;
  501. color: #fff;
  502. }
  503. }
  504. }
  505. </style>