LightMap.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. <template>
  2. <div
  3. class="light-map"
  4. :class="{
  5. 'full': full ,
  6. 'small': small
  7. }"
  8. @click="emit('mapClick')"
  9. >
  10. <slot />
  11. <map
  12. id="prevMap"
  13. map-id="prevMap"
  14. class="light-map-map"
  15. :style="{
  16. pointerEvents: small ? 'none' : undefined
  17. }"
  18. :show-location="true"
  19. :enable-poi="!small"
  20. :scale="12"
  21. :longitude="lonlat?.longitude"
  22. :latitude="lonlat?.latitude"
  23. @markertap="onMarkerTap"
  24. />
  25. <!-- 状态显示 -->
  26. <FlexCol v-if="mapLoader.error.value"
  27. gap="gap.md"
  28. position="absolute"
  29. inset="0"
  30. center
  31. >
  32. <FlexCol center gap="gap.md" padding="padding.md" backgroundColor="white" radius="radius.md">
  33. <Icon name="warning" size="44" color="red" />
  34. <Text textAlign="center" :text="mapLoader.error.value" />
  35. </FlexCol>
  36. </FlexCol>
  37. <FlexCol v-else-if="mapLoader.status.value === 'loading'"
  38. gap="gap.md"
  39. position="absolute"
  40. inset="0"
  41. center
  42. >
  43. <ActivityIndicator :size="64" />
  44. </FlexCol>
  45. <!-- 导航栏 -->
  46. <FlexCol
  47. position="absolute"
  48. :inset="{ l: 0, b: 0,r: 0 }"
  49. :zIndex="20"
  50. :padding="full ? [0, 10] : 20"
  51. >
  52. <FlexRow align="center" justify="space-between">
  53. <view v-if="small"></view>
  54. <template v-else>
  55. <BoxMid direction="row" wrap gap="gap.md" align="center" :padding="15">
  56. <NButton
  57. :text="city + ' ▼'"
  58. color="transparent"
  59. type="custom"
  60. :radius="30"
  61. :padding="30"
  62. :textProps="{
  63. maxWidth: 150,
  64. lines: 1
  65. }"
  66. @click="emit('changeCity')"
  67. />
  68. <SimpleDropDownPicker
  69. class="light-map-region-picker"
  70. backgroundColor="transparent"
  71. :modelValue="selectedRegion"
  72. @update:modelValue="onSelectedRegion"
  73. :columns="regionLoader.content.value"
  74. />
  75. <SimpleDropDownPicker
  76. class="light-map-region-picker"
  77. backgroundColor="transparent"
  78. v-model:modelValue="lightState"
  79. :columns="[
  80. {
  81. id: 1,
  82. name: '点亮状态',
  83. },
  84. {
  85. id: 2,
  86. name: '已点亮',
  87. },
  88. {
  89. id: 3,
  90. name: '未点亮',
  91. },
  92. ]"
  93. />
  94. <FrameButton
  95. :icon="searchKeyword ? 'close' : 'search'"
  96. size="small"
  97. @click="showSearch"
  98. />
  99. <FrameButton
  100. icon="navigation"
  101. size="small"
  102. @click="emit('getCurrentLonlat')"
  103. />
  104. <Width />
  105. </BoxMid>
  106. </template>
  107. </FlexRow>
  108. <StatusBarSpace v-if="full" />
  109. </FlexCol>
  110. <!-- 上下渐变遮罩 -->
  111. <BackgroundBox
  112. v-if="full"
  113. position="fixed"
  114. :inset="{ l: 0, t: 0, r: 0 }"
  115. :height="500"
  116. :zIndex="19"
  117. color2="transparent"
  118. color1="white"
  119. />
  120. <BackgroundBox
  121. v-if="full"
  122. position="fixed"
  123. :inset="{ l: 0, b: 0, r: 0 }"
  124. :zIndex="19"
  125. :height="300"
  126. color1="transparent"
  127. color2="white"
  128. />
  129. <!-- 搜索村社 -->
  130. <Dialog
  131. v-model:show="searchDialogShow"
  132. title="搜索村社"
  133. showCancel
  134. :maskClosable="false"
  135. :onConfirm="searchConfirm"
  136. >
  137. <template #content>
  138. <Field v-model="searchKeyword" placeholder="请输入村社名称进行搜索" />
  139. </template>
  140. </Dialog>
  141. </div>
  142. </template>
  143. <script setup lang="ts">
  144. import { computed, getCurrentInstance, onMounted, ref, watch } from 'vue';
  145. import { assertNotNull, waitTimeOut } from '@imengyu/imengyu-utils';
  146. import { toast } from '@/components/dialog/CommonRoot';
  147. import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
  148. import LightVillageApi, { type VillageMapItem } from '@/api/light/LightVillageApi';
  149. import AppCofig from '@/common/config/AppCofig';
  150. import SimpleDropDownPicker from '@/common/components/SimpleDropDownPicker.vue';
  151. import NButton from '@/components/basic/Button.vue';
  152. import FlexCol from '@/components/layout/FlexCol.vue';
  153. import Text from '@/components/basic/Text.vue';
  154. import Icon from '@/components/basic/Icon.vue';
  155. import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
  156. import Dialog from '@/components/dialog/Dialog.vue';
  157. import Field from '@/components/form/Field.vue';
  158. import FlexRow from '@/components/layout/FlexRow.vue';
  159. import StatusBarSpace from '@/components/layout/space/StatusBarSpace.vue';
  160. import RegionApi from '@/api/map/RegionApi';
  161. import type { MapMarker } from '@/types/Map';
  162. import { hideLoading, loading } from '@/components/utils/DialogAction';
  163. import { showError } from '@/common/composeabe/ErrorDisplay';
  164. import FrameButton from '@/common/components/FrameButton.vue';
  165. import BoxMid from '@/common/components/box/BoxMid.vue';
  166. import Width from '@/components/layout/space/Width.vue';
  167. import BackgroundBox from '@/components/display/block/BackgroundBox.vue';
  168. const instance = getCurrentInstance();
  169. const mapCtx = uni.createMapContext('prevMap', instance);
  170. const selectedRegion = ref<number>();
  171. const villageData = new Map<number, VillageMapItem>();
  172. const ready = ref(false);
  173. const hasResItems = ref(false);
  174. const searchDialogShow = ref(false);
  175. const searchKeyword = ref('');
  176. const emit = defineEmits([
  177. 'getCurrentLonlat',
  178. 'update:isLightMode',
  179. 'update:lonlat',
  180. 'update:district',
  181. 'selectVillage',
  182. 'regionChanged',
  183. 'lightVillage',
  184. 'createVillage',
  185. 'changeCity',
  186. 'mapClick',
  187. ]);
  188. const props = withDefaults(defineProps<{
  189. lonlat?: { longitude: number, latitude: number } | undefined;
  190. city?: string;
  191. cityCode?: number;
  192. district?: string;
  193. isLightMode?: boolean;
  194. small?: boolean;
  195. full?: boolean;
  196. }>(), {
  197. });
  198. const lightState = ref(1);
  199. const showLighted = computed(() => lightState.value === 2 || lightState.value === 1);
  200. const showUnLighted = computed(() => lightState.value === 3 || lightState.value === 1);
  201. const regionLoader = useSimpleDataLoader(async () => {
  202. if (!props.cityCode)
  203. return [];
  204. return ((await RegionApi.getChildList(props.cityCode))
  205. .filter(p => p.areaCode !== props.cityCode && p.name !== '市辖区')
  206. .map(p => ({
  207. ...p,
  208. name: p.name,
  209. id: p.areaCode,
  210. })));
  211. }, false);
  212. const mapLoader = useSimpleDataLoader<MapMarker[]>(async () => {
  213. mapCtx.removeMarkers({
  214. markerIds: Array.from(villageData.keys()),
  215. })
  216. villageData.clear();
  217. if (!selectedRegion.value)
  218. return [];
  219. await waitTimeOut(100);
  220. let res = (await LightVillageApi.getMapVillage({
  221. areaCode: selectedRegion.value,
  222. }));
  223. if (searchKeyword.value) {
  224. res = res.filter(p => p.name.includes(searchKeyword.value));
  225. }
  226. //如果为空则尝试获取子级
  227. hasResItems.value = res.length > 0;
  228. const list = res
  229. .filter(p => {
  230. if (p.isLight && showLighted.value)
  231. return true;
  232. if (!p.isLight && showUnLighted.value)
  233. return true;
  234. return false;
  235. })
  236. .map((p, i) => {
  237. const id = p.villageId ?? i;
  238. villageData.set(id, p);
  239. const maker : MapMarker = {
  240. id,
  241. title: p.name,
  242. longitude: Number(p.longitude),
  243. latitude: Number(p.latitude),
  244. width: 30,
  245. height: 30,
  246. iconPath: '',
  247. joinCluster: true,
  248. callout: {
  249. display: props.small ? 'BYCLICK' : 'ALWAYS',
  250. content: p.name,
  251. color: '#000000',
  252. fontSize: 12,
  253. padding: 5,
  254. bgColor: '#ffffff',
  255. borderRadius: 5,
  256. },
  257. }
  258. if (p.isLight && p.villageId) {
  259. if (p.lightValue >= 1) {
  260. maker.iconPath = `https://mncdn.wenlvti.net/app_static/xiangyuan/images/map/Light3.png`;
  261. } else if (p.lightValue >= 0.5) {
  262. maker.iconPath = `https://mncdn.wenlvti.net/app_static/xiangyuan/images/map/Light2.png`;
  263. } else if (p.lightValue >= 0.25) {
  264. maker.iconPath = `https://mncdn.wenlvti.net/app_static/xiangyuan/images/map/Light1.png`;
  265. } else {
  266. maker.iconPath = `https://mncdn.wenlvti.net/app_static/xiangyuan/images/map/Light1.png`;
  267. }
  268. const size = Math.floor(30 +(p.lightValue) * 40);
  269. maker.width = size;
  270. maker.height = size * 1.05;
  271. } else {
  272. maker.width = 25;
  273. maker.height = 27;
  274. maker.iconPath = `https://mncdn.wenlvti.net/app_static/xiangyuan/images/map/LightUnLight.png`;
  275. }
  276. return maker as MapMarker;
  277. }).filter(p =>
  278. p.longitude && p.latitude
  279. && !isNaN(p.longitude) && !isNaN(p.latitude)
  280. && p.longitude > -180 && p.longitude < 180
  281. && p.latitude > -90 && p.latitude < 90
  282. );
  283. asyncAddMarkers(list);
  284. if (res.length == 1) {
  285. mapCtx.moveToLocation({
  286. latitude: Number(list[0].latitude),
  287. longitude: Number(list[0].longitude),
  288. });
  289. } else if (res.length > 1) {
  290. setTimeout(() => {
  291. mapCtx.includePoints({
  292. points: list.map(p => {
  293. if (!p.longitude || !p.latitude) {
  294. p.longitude = AppCofig.defaultLonLat[0];
  295. p.latitude = AppCofig.defaultLonLat[1];
  296. }
  297. return {
  298. latitude: p.latitude,
  299. longitude: p.longitude,
  300. }
  301. }),
  302. padding: [20, 20, 20, 20],
  303. });
  304. }, 200);
  305. } else {
  306. try {
  307. const currentRegion = regionLoader.content.value?.find(p => p.id == selectedRegion.value);
  308. if (currentRegion) {
  309. mapCtx.moveToLocation({
  310. latitude: Number(currentRegion.latitude),
  311. longitude: Number(currentRegion.longitude),
  312. });
  313. }
  314. } catch (error) {
  315. console.error(error);
  316. }
  317. }
  318. ready.value = true;
  319. return list;
  320. }, false, false);
  321. const BATCH_SIZE = 25;
  322. async function asyncAddMarkers(list: MapMarker[]) {
  323. if (list.length <= BATCH_SIZE) {
  324. mapCtx.addMarkers({
  325. clear: true,
  326. markers: list,
  327. });
  328. return;
  329. }
  330. for (let i = 0; i < list.length; i += BATCH_SIZE) {
  331. const batch = list.slice(i, i + BATCH_SIZE);
  332. mapCtx.addMarkers({
  333. clear: i === 0,
  334. markers: batch,
  335. });
  336. if (i + BATCH_SIZE < list.length) {
  337. await waitTimeOut(300);
  338. }
  339. }
  340. }
  341. async function onMarkerTap(e: any) {
  342. try {
  343. loading();
  344. const villageMapInfo = villageData.get(e.markerId);
  345. assertNotNull(villageMapInfo, 'markerId错误');
  346. if (!villageMapInfo.villageId) {
  347. emit('createVillage', villageMapInfo);
  348. return;
  349. }
  350. const village = await LightVillageApi.getVillageDetails(villageMapInfo.villageId);
  351. assertNotNull(village, '获取村社详情失败');
  352. if (props.isLightMode) {
  353. emit('update:isLightMode', false);
  354. emit('lightVillage', village);
  355. return;
  356. }
  357. emit('selectVillage', village);
  358. } catch (error) {
  359. showError(error);
  360. } finally {
  361. hideLoading();
  362. }
  363. }
  364. function onSelectedRegion(regionId: number) {
  365. selectedRegion.value = regionId;
  366. mapLoader.reload();
  367. emit('update:district', regionLoader.content.value?.find(p => p.id === regionId)?.name);
  368. emit('regionChanged', regionId);
  369. }
  370. function setCurrentRegion(regionAreaCode: number) {
  371. if (regionAreaCode === selectedRegion.value)
  372. return;
  373. selectedRegion.value = regionAreaCode;
  374. emit('regionChanged', selectedRegion.value);
  375. mapLoader.reload();
  376. }
  377. function loadFirstRegion() {
  378. //加载第一个地区(排除市辖区)
  379. let code : number | undefined = undefined;
  380. if (props.district)
  381. code = regionLoader.content.value?.find((p) => p.name === props.district)?.areaCode;
  382. if (!code)
  383. code = regionLoader.content.value?.find((p) => p.id !== props.cityCode)?.areaCode;
  384. if (!code)
  385. code = regionLoader.content.value?.[0]?.areaCode;
  386. if (code)
  387. setCurrentRegion(code);
  388. console.log('加载区域数据', code, regionLoader.content.value, props.district);
  389. }
  390. function showSearch() {
  391. if (searchKeyword.value) {
  392. searchKeyword.value = '';
  393. mapLoader.reload();
  394. } else {
  395. searchDialogShow.value = true;
  396. searchKeyword.value = '';
  397. }
  398. }
  399. async function searchConfirm() {
  400. searchDialogShow.value = false;
  401. if (searchKeyword.value.trim() === '') {
  402. toast('请输入搜索关键词');
  403. return;
  404. }
  405. await mapLoader.reload();
  406. if (mapLoader.content.value?.length === 0) {
  407. toast('未搜索到相关村社,换个关键词再试试吧');
  408. return;
  409. }
  410. }
  411. watch(() => props.cityCode, async () => {
  412. await regionLoader.reload();
  413. loadFirstRegion();
  414. }, { immediate: true });
  415. watch(showLighted, () => mapLoader.reload());
  416. watch(showUnLighted, () => mapLoader.reload());
  417. onMounted(async () => {
  418. mapCtx.initMarkerCluster({
  419. enableDefaultStyle: false,
  420. zoomOnClick: true,
  421. gridSize: props.small ? 10 : 35,
  422. });
  423. mapCtx.on('markerClusterCreate', (e: { clusters: any[] }) => {
  424. const customClusters = e.clusters.map((cluster) => {
  425. const { center, clusterId, markerIds } = cluster;
  426. return {
  427. ...center,
  428. width: 0,
  429. height: 0,
  430. clusterId,
  431. label: {
  432. content: markerIds.length.toString(), // 聚合点的数量
  433. fontSize: 16,
  434. color: '#fff',
  435. width: 30,
  436. height: 30,
  437. bgColor: '#8bb346', // 背景颜色
  438. borderRadius: 25,
  439. textAlign: 'center',
  440. anchorX: -10,
  441. anchorY: -35,
  442. },
  443. };
  444. });
  445. mapCtx.addMarkers({
  446. markers: customClusters,
  447. clear: false,
  448. });
  449. });
  450. });
  451. defineExpose({
  452. showSearch,
  453. reload: async () => {
  454. await regionLoader.reload();
  455. loadFirstRegion();
  456. }
  457. });
  458. </script>
  459. <style lang="scss">
  460. .light-map {
  461. position: relative;
  462. width: 100%;
  463. height: 600rpx;
  464. border-radius: 30rpx;
  465. overflow: hidden;
  466. border: 1px solid #d45652;
  467. &.full {
  468. height: 100vh;
  469. border-radius: 0;
  470. .light-map-map {
  471. height: 100vh;
  472. }
  473. }
  474. &.small {
  475. height: 400rpx;
  476. border-radius: 20rpx;
  477. .light-map-map {
  478. height: 400rpx;
  479. }
  480. }
  481. .light-map-map {
  482. width: 100%;
  483. height: 600rpx;
  484. }
  485. .light-map-address {
  486. position: absolute;
  487. bottom: 20rpx;
  488. right: 20rpx;
  489. z-index: 100;
  490. }
  491. }
  492. </style>