albumMap.vue 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. <template>
  2. <div class="album-map-page">
  3. <custom-header :name="t('agriFile.agriAlbum')" />
  4. <div class="album-map-content">
  5. <div class="map-container" ref="mapContainer"></div>
  6. <div class="search-bar">
  7. <location-search
  8. class="search-bar__search"
  9. :user-location="userLocation"
  10. @change="handleLocationChange"
  11. />
  12. </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. v-for="item in zoneList"
  18. :key="item.id"
  19. :ref="(el) => setMarkerEl(item.id, el)"
  20. class="zone-marker"
  21. >
  22. <div class="zone-marker__label">{{ item.name }}</div>
  23. <div class="zone-marker__thumb">
  24. <img :src="item.cover" alt="" />
  25. <span v-if="item.count" class="zone-marker__badge">{{ item.count }}</span>
  26. </div>
  27. <div class="zone-marker__arrow"></div>
  28. </div>
  29. </div>
  30. </div>
  31. </template>
  32. <script setup>
  33. import { nextTick, onActivated, onBeforeUnmount, onMounted, ref } from "vue";
  34. import { useStore } from "vuex";
  35. import Overlay from "ol/Overlay";
  36. import customHeader from "@/components/customHeader.vue";
  37. import locationSearch from "@/components/pageComponents/locationSearch.vue";
  38. import FileMap from "../fileMap";
  39. import * as util from "@/common/ol_common.js";
  40. import { base_img_url2 } from "@/api/config";
  41. import { useI18n } from "@/i18n";
  42. const { t } = useI18n();
  43. const store = useStore();
  44. // ---------- 常量 ----------
  45. const defaultCover = require("@/assets/img/agricultural/photo.png");
  46. const DEFAULT_CENTER = [113.6142086995688, 23.585836479509055];
  47. // ---------- 页面状态 ----------
  48. const mapContainer = ref(null);
  49. const fileMap = new FileMap();
  50. fileMap.fitPadding = [68, 50, 50, 50];
  51. const markerEls = {};
  52. const mapOverlays = [];
  53. const zoneList = ref([]);
  54. const userLocation = ref(
  55. store.state.home.miniUserLocation ||
  56. localStorage.getItem("MINI_USER_LOCATION") ||
  57. "113.61702297075017,23.584863449735067"
  58. );
  59. // ---------- 工具函数 ----------
  60. function getSelectedFarmId() {
  61. const stored = localStorage.getItem("selectedFarmId");
  62. if (stored != null && stored !== "") {
  63. return Number(stored);
  64. }
  65. try {
  66. const farm = JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
  67. const id = farm.farm_id ?? farm.id;
  68. return id != null && id !== "" ? Number(id) : null;
  69. } catch {
  70. return null;
  71. }
  72. }
  73. function parsePointCoordinates(pointWkt) {
  74. if (!pointWkt || !/^POINT\s*\(/i.test(String(pointWkt).trim())) {
  75. return null;
  76. }
  77. try {
  78. const coord = util.wktCastGeom(pointWkt).getFirstCoordinate();
  79. if (!coord || coord.length < 2) return null;
  80. return { longitude: coord[0], latitude: coord[1] };
  81. } catch {
  82. return null;
  83. }
  84. }
  85. function resolveZoneCoverUrl(path) {
  86. if (!path) return defaultCover;
  87. const text = String(path).trim();
  88. if (!text) return defaultCover;
  89. if (/^https?:\/\//i.test(text)) return text;
  90. return base_img_url2 + text.replace(/^\//, "");
  91. }
  92. function mapZoneItem(zone) {
  93. const coords = parsePointCoordinates(zone?.point);
  94. return {
  95. id: zone.zone_id,
  96. zoneId: zone.zone_id,
  97. zone_id: zone.zone_id,
  98. name: zone.zone_name || "",
  99. count: zone.image_count ?? 0,
  100. cover: resolveZoneCoverUrl(zone.cover_url),
  101. longitude: coords?.longitude,
  102. latitude: coords?.latitude,
  103. point: zone.point,
  104. };
  105. }
  106. const setMarkerEl = (id, el) => {
  107. if (el) markerEls[id] = el;
  108. else delete markerEls[id];
  109. };
  110. const getFarmCenter = () => {
  111. try {
  112. const farm = JSON.parse(localStorage.getItem("selectedFarmData") || "{}");
  113. const wkt = farm.wkt || farm.geom_wkt || farm.farm_location;
  114. if (typeof wkt === "string" && /^POINT\s*\(/i.test(wkt.trim())) {
  115. return util.wktCastGeom(wkt).getFirstCoordinate();
  116. }
  117. } catch {
  118. // ignore
  119. }
  120. const firstZone = zoneList.value.find((z) => z.longitude != null && z.latitude != null);
  121. if (firstZone) {
  122. return [firstZone.longitude, firstZone.latitude];
  123. }
  124. return DEFAULT_CENTER;
  125. };
  126. // ---------- 业务逻辑:地图 / 区域 ----------
  127. const clearMapOverlays = () => {
  128. if (!fileMap.kmap?.map) {
  129. mapOverlays.length = 0;
  130. return;
  131. }
  132. mapOverlays.forEach((overlay) => fileMap.kmap.map.removeOverlay(overlay));
  133. mapOverlays.length = 0;
  134. };
  135. const bindZoneOverlays = () => {
  136. if (!fileMap.kmap?.map) return;
  137. clearMapOverlays();
  138. zoneList.value.forEach((zone) => {
  139. const el = markerEls[zone.id];
  140. if (!el || zone.longitude == null || zone.latitude == null) return;
  141. const overlay = new Overlay({
  142. element: el,
  143. position: [zone.longitude, zone.latitude],
  144. positioning: "bottom-center",
  145. stopEvent: false,
  146. });
  147. fileMap.kmap.map.addOverlay(overlay);
  148. mapOverlays.push(overlay);
  149. });
  150. };
  151. const fitZonesOnMap = () => {
  152. const records = zoneList.value
  153. .filter((z) => z.longitude != null && z.latitude != null)
  154. .map((z) => ({
  155. zone_name: "",
  156. polygon: `POINT(${z.longitude} ${z.latitude})`,
  157. }));
  158. if (!records.length) return;
  159. fileMap.setRecordPolygons(records, "album");
  160. };
  161. async function fetchZoneList() {
  162. const farmId = getSelectedFarmId();
  163. if (!farmId) {
  164. zoneList.value = [];
  165. return;
  166. }
  167. try {
  168. const res = await VE_API.questionnaire.getZones({ farm_id: farmId });
  169. const zones = res?.data?.zones;
  170. zoneList.value = Array.isArray(zones) ? zones.map(mapZoneItem) : [];
  171. } catch (e) {
  172. console.warn("[albumMap] getZones failed", e);
  173. zoneList.value = [];
  174. }
  175. }
  176. const initAlbumMap = async () => {
  177. await nextTick();
  178. if (!mapContainer.value) return;
  179. await fetchZoneList();
  180. const center = getFarmCenter();
  181. fileMap.initMap(`POINT(${center[0]} ${center[1]})`, mapContainer.value);
  182. await nextTick();
  183. bindZoneOverlays();
  184. fileMap.kmap?.map?.updateSize?.();
  185. fitZonesOnMap();
  186. };
  187. // ---------- 事件处理 ----------
  188. const handleLocationChange = (payload) => {
  189. if (!payload?.coordinateArray || !fileMap.kmap) return;
  190. fileMap.kmap.getView().animate({
  191. center: payload.coordinateArray,
  192. zoom: 16,
  193. duration: 0,
  194. });
  195. };
  196. const handleLocate = () => {
  197. if (!fileMap.kmap) return;
  198. const center = getFarmCenter();
  199. fileMap.kmap.getView().animate({
  200. center,
  201. zoom: 16,
  202. duration: 0,
  203. });
  204. };
  205. // ---------- 生命周期 ----------
  206. onMounted(initAlbumMap);
  207. onActivated(initAlbumMap);
  208. onBeforeUnmount(clearMapOverlays);
  209. </script>
  210. <style lang="scss" scoped>
  211. .album-map-page {
  212. display: flex;
  213. flex-direction: column;
  214. width: 100%;
  215. height: 100vh;
  216. overflow: hidden;
  217. background: #fff;
  218. }
  219. .album-map-content {
  220. position: relative;
  221. flex: 1;
  222. min-height: 0;
  223. overflow: hidden;
  224. .map-container {
  225. width: 100%;
  226. height: 100%;
  227. }
  228. }
  229. .zone-marker {
  230. display: flex;
  231. flex-direction: column;
  232. align-items: center;
  233. pointer-events: none;
  234. &__label {
  235. padding: 3px 14px;
  236. border-radius: 25px;
  237. background: #fff;
  238. color: #0a0a0a;
  239. font-size: 12px;
  240. }
  241. &__thumb {
  242. position: relative;
  243. width: 42px;
  244. height: 42px;
  245. margin-top: 8px;
  246. img {
  247. width: 100%;
  248. height: 100%;
  249. object-fit: cover;
  250. border: 2px solid #fff;
  251. border-radius: 6px;
  252. box-sizing: border-box;
  253. }
  254. }
  255. &__badge {
  256. position: absolute;
  257. top: -6px;
  258. right: -6px;
  259. min-width: 18px;
  260. height: 18px;
  261. line-height: 16px;
  262. border-radius: 50%;
  263. background: #fff;
  264. color: #0a0a0a;
  265. font-size: 12px;
  266. text-align: center;
  267. }
  268. &__arrow {
  269. width: 0;
  270. height: 0;
  271. border-left: 5px solid transparent;
  272. border-right: 5px solid transparent;
  273. border-top: 6px solid #fff;
  274. }
  275. }
  276. .search-bar {
  277. position: absolute;
  278. top: 12px;
  279. left: 12px;
  280. right: 12px;
  281. z-index: 2;
  282. display: flex;
  283. align-items: center;
  284. height: 40px;
  285. padding: 0 4px 0 12px;
  286. border-radius: 20px;
  287. background: rgba(0, 0, 0, 0.45);
  288. box-sizing: border-box;
  289. &__search {
  290. flex: 1;
  291. min-width: 0;
  292. :deep(.el-select__wrapper) {
  293. background: transparent;
  294. box-shadow: none;
  295. border: none;
  296. min-height: 40px;
  297. padding-left: 0;
  298. }
  299. :deep(.el-select__placeholder),
  300. :deep(.el-select__input) {
  301. color: rgba(255, 255, 255, 0.7);
  302. }
  303. :deep(.el-icon) {
  304. color: rgba(255, 255, 255, 0.85);
  305. }
  306. }
  307. }
  308. .locate-btn {
  309. position: absolute;
  310. right: 12px;
  311. bottom: 96px;
  312. z-index: 2;
  313. display: flex;
  314. align-items: center;
  315. justify-content: center;
  316. width: 36px;
  317. height: 36px;
  318. border-radius: 8px;
  319. background: #fff;
  320. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  321. &__icon {
  322. width: 16px;
  323. height: 18px;
  324. }
  325. }
  326. </style>