diff --git a/env/.env b/env/.env index 5d9b62c..47ef0e2 100644 --- a/env/.env +++ b/env/.env @@ -10,8 +10,8 @@ VITE_WX_APPID = 'wx1a832d51073d3a35' VITE_APP_PUBLIC_BASE=/ # 后台请求地址 -VITE_SERVER_BASEURL = 'http://localhost:48080/admin-api' -VITE_UPLOAD_BASEURL = 'http://localhost:48080/upload' +VITE_SERVER_BASEURL = 'http://192.168.1.107:48080/admin-api' +VITE_UPLOAD_BASEURL = 'http://192.168.1.107:48080/upload' # 备注:如果后台带统一前缀,则也要加到后面,eg: https://ukw0y1.laf.run/api # 注意,如果是微信小程序,还有一套请求地址的配置,根据 develop、trial、release 分别设置上传地址,见 `src/utils/index.ts`。 diff --git a/env/.env.development b/env/.env.development index 496bbe1..4da39b0 100644 --- a/env/.env.development +++ b/env/.env.development @@ -6,4 +6,4 @@ VITE_DELETE_CONSOLE = false VITE_SHOW_SOURCEMAP = false # 后台请求地址 -# VITE_SERVER_BASEURL = 'http://localhost:48080' +# VITE_SERVER_BASEURL = 'http://1921.168.1.107:48080' diff --git a/src/api/agri/operation.ts b/src/api/agri/operation.ts index 5ad6af8..b85109b 100644 --- a/src/api/agri/operation.ts +++ b/src/api/agri/operation.ts @@ -49,6 +49,8 @@ export interface AgriOperationFormReqVO { workContent?: string imageUrls?: string remark?: string + longitude?: number + latitude?: number } /** 获取农事操作分页列表 */ diff --git a/src/api/agri/plot.ts b/src/api/agri/plot.ts new file mode 100644 index 0000000..c4df4ef --- /dev/null +++ b/src/api/agri/plot.ts @@ -0,0 +1,18 @@ +import { http } from '@/http/http' + +export interface AgriPlotVO { + id: number + plotCode?: string + plotName?: string + area?: number + boundaryGeojson?: string + centerLongitude?: number + centerLatitude?: number + managerName?: string + status?: number + remark?: string +} + +export function getPlot(id: number) { + return http.get(`/agri/plot/get?id=${id}`) +} diff --git a/src/pages-agri/operation/form/index.vue b/src/pages-agri/operation/form/index.vue index 5822fa8..e58ed4c 100644 --- a/src/pages-agri/operation/form/index.vue +++ b/src/pages-agri/operation/form/index.vue @@ -85,6 +85,26 @@ /> + + + + + {{ mapStatusText }} + 定位当前位置 + + + + @@ -107,9 +127,12 @@ import type { FormInstance } from 'wot-design-uni/components/wd-form/types' import type { UploadFile, UploadMethod } from 'wot-design-uni/components/wd-upload/types' import { OPERATION_TYPE_MAP } from '@/api/agri/operation' import type { AgriBatchVO } from '@/api/agri/batch' -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { onShow } from '@dcloudio/uni-app' import { useToast } from 'wot-design-uni' import { getEnvBaseUrl, navigateBackPlus } from '@/utils' +import { getGeoJsonPolygonPaths, isPointInGeoJson } from '@/utils/geo' +import { getPlot } from '@/api/agri/plot' import { createOperation, getOperation, @@ -161,12 +184,66 @@ const formData = ref(createDefaultFormData()) /** 图片列表 */ const fileList = ref([]) +const locationPoint = ref<{ longitude: number, latitude: number } | null>(null) +const mapScale = ref(16) +const mapCenter = ref({ longitude: 116.397428, latitude: 39.90923 }) +const mapPolygons = ref([]) +const currentPlotGeoJson = ref(null) +const mapReady = ref(false) +const locationLoading = ref(false) +const locationWatchActive = ref(false) +const locationDenied = ref(false) +let locationWatchTimer: ReturnType | null = null const formRules = { batchId: [{ required: true, message: '请选择批次' }], operationType: [{ required: true, message: '请选择操作类型' }], } +const mapMarkers = computed(() => { + if (!locationPoint.value) { + return [] + } + return [{ + id: 1, + longitude: locationPoint.value.longitude, + latitude: locationPoint.value.latitude, + width: 28, + height: 28, + title: '当前位置', + }] +}) + +const inPlotRange = computed(() => { + if (!locationPoint.value || !currentPlotGeoJson.value) { + return false + } + return isPointInGeoJson(locationPoint.value, currentPlotGeoJson.value) +}) + +const mapStatusText = computed(() => { + if (locationLoading.value) { + return '正在获取当前位置...' + } + if (!formData.value.batchId) { + return '请选择批次后查看地块范围' + } + if (!currentPlotGeoJson.value) { + return '当前批次暂无可展示的地块范围' + } + if (!locationPoint.value || locationDenied.value) { + return '需要开启定位权限,用于显示当前位置和判断是否在地块范围内' + } + return inPlotRange.value ? '当前位于地块范围内' : '当前位置不在地块范围内' +}) + +const mapStatusClass = computed(() => { + if (!locationPoint.value || !currentPlotGeoJson.value || locationLoading.value) { + return 'is-neutral' + } + return inPlotRange.value ? 'is-success' : 'is-warning' +}) + /** 批次下拉列 */ const batchColumns = computed(() => batchList.value @@ -213,11 +290,206 @@ function buildUploadFileList(imageUrls?: string) { })) } +function getSelectedPlotId() { + const selectedBatch = batchList.value.find(batch => batch.id === formData.value.batchId) + return selectedBatch?.plotId +} + +function parseBoundaryGeoJson(boundaryGeojson?: string) { + if (!boundaryGeojson) { + return null + } + try { + return JSON.parse(boundaryGeojson) + } catch { + return null + } +} + +function updateMapCenter(longitude: number, latitude: number) { + mapCenter.value = { longitude, latitude } +} + +function applyPlotGeoJsonToMap(geoJson: any) { + currentPlotGeoJson.value = geoJson + const polygonPaths = getGeoJsonPolygonPaths(geoJson) + mapPolygons.value = polygonPaths.map((points, index) => ({ + id: index + 1, + points, + strokeWidth: 2, + strokeColor: '#1E80FF', + fillColor: 'rgba(30,128,255,0.20)', + })) + + if (!locationPoint.value && polygonPaths[0]?.[0]) { + updateMapCenter(polygonPaths[0][0].longitude, polygonPaths[0][0].latitude) + } +} + +function clearPlotMap() { + currentPlotGeoJson.value = null + mapPolygons.value = [] +} + +async function loadSelectedPlotMap() { + const plotId = getSelectedPlotId() + if (!plotId) { + clearPlotMap() + return + } + + try { + const plot = await getPlot(plotId) + const geoJson = parseBoundaryGeoJson(plot?.boundaryGeojson) + if (!geoJson) { + clearPlotMap() + return + } + applyPlotGeoJsonToMap(geoJson) + } catch { + clearPlotMap() + } +} + +function getCurrentLocation() { + return new Promise<{ longitude: number, latitude: number }>((resolve, reject) => { + uni.getLocation({ + type: 'gcj02', + success: (res) => { + resolve({ + longitude: Number(res.longitude), + latitude: Number(res.latitude), + }) + }, + fail: reject, + }) + }) +} + +function openLocationPermissionSetting() { + uni.showModal({ + title: '定位权限提醒', + content: '需要开启定位权限,用于显示当前位置和判断是否在地块范围内', + confirmText: '去开启', + success: (res) => { + if (!res.confirm) { + return + } + if (typeof uni.openSetting === 'function') { + uni.openSetting({}) + } + }, + }) +} + +function handleLocationFailure(showPermissionToast: boolean) { + locationPoint.value = null + locationDenied.value = true + if (!showPermissionToast) { + return + } + openLocationPermissionSetting() +} + +async function refreshCurrentLocation(showPermissionToast = true) { + locationLoading.value = true + try { + const currentLocation = await getCurrentLocation() + locationPoint.value = currentLocation + locationDenied.value = false + updateMapCenter(currentLocation.longitude, currentLocation.latitude) + mapReady.value = true + return currentLocation + } catch { + handleLocationFailure(showPermissionToast) + return null + } finally { + locationLoading.value = false + } +} + +async function handleRelocate() { + await refreshCurrentLocation(true) +} + +function stopLocationWatch() { + if (locationWatchTimer) { + clearInterval(locationWatchTimer) + locationWatchTimer = null + } + locationWatchActive.value = false +} + +function startLocationWatch() { + stopLocationWatch() + if (currentId.value) { + return + } + locationWatchActive.value = true + locationWatchTimer = setInterval(() => { + refreshCurrentLocation(false) + }, 10000) +} + +// 当前使用 gcj02 定位坐标参与圈地判断,默认要求地块 boundaryGeojson 采用相同坐标系。 +// 若圈地数据来源坐标系不是 gcj02,需要在后端或数据入库环节统一,而不是在这里做隐式转换。 +async function checkLocationInLand() { + const plotId = getSelectedPlotId() + if (!plotId) { + toast.show('请选择批次') + return false + } + + const currentLocation = await refreshCurrentLocation(false) + if (!currentLocation) { + toast.show('定位获取失败,请开启定位权限后重试') + return false + } + + let geoJson = currentPlotGeoJson.value + if (!geoJson) { + try { + const plot = await getPlot(plotId) + geoJson = parseBoundaryGeoJson(plot?.boundaryGeojson) + if (geoJson) { + applyPlotGeoJsonToMap(geoJson) + } + } catch { + toast.show('地块范围获取失败,请稍后重试') + return false + } + } + + if (!geoJson) { + toast.show('该地块范围数据格式异常,暂无法提交') + return false + } + + const inRange = isPointInGeoJson(currentLocation, geoJson) + if (!inRange) { + toast.show('当前位置不在该地块范围内,请移动到地块范围内后再提交') + return false + } + + return true +} + /** 批次选择回调 */ function onBatchConfirm({ value }: { value: unknown }) { formData.value.batchId = normalizePickerValue(value) } +watch(() => formData.value.batchId, async (batchId) => { + if (!batchId || currentId.value) { + clearPlotMap() + return + } + await loadSelectedPlotMap() + if (!mapReady.value) { + await refreshCurrentLocation(false) + } +}) + /** 操作类型选择回调 */ function onTypeConfirm({ value }: { value: unknown }) { formData.value.operationType = normalizePickerValue(value) @@ -305,6 +577,8 @@ function buildSubmitData() { operatorName: formData.value.operatorName || '', workContent: formData.value.workContent || '', remark: formData.value.remark || '', + longitude: locationPoint.value?.longitude, + latitude: locationPoint.value?.latitude, } if (operationDateTimestamp.value) { const date = new Date(operationDateTimestamp.value) @@ -335,6 +609,11 @@ async function handleSubmit() { const { valid } = await formRef.value!.validate() if (!valid) return + if (!currentId.value) { + const checked = await checkLocationInLand() + if (!checked) return + } + formLoading.value = true try { const submitData = buildSubmitData() @@ -360,8 +639,59 @@ onMounted(async () => { operationDateTimestamp.value = Date.now() await loadBatchList() await getDetail() + if (!currentId.value) { + await refreshCurrentLocation(true) + await loadSelectedPlotMap() + startLocationWatch() + } +}) + +onShow(() => { + if (!currentId.value) { + refreshCurrentLocation(false) + } +}) + +onUnmounted(() => { + stopLocationWatch() }) diff --git a/src/utils/geo.js b/src/utils/geo.js new file mode 100644 index 0000000..43852d6 --- /dev/null +++ b/src/utils/geo.js @@ -0,0 +1,156 @@ +export function isPointInGeoJson(point, geoJson) { + if (!point || !isFiniteNumber(point.longitude) || !isFiniteNumber(point.latitude) || !geoJson) { + return false + } + + const geometries = collectGeometries(geoJson) + if (!geometries.length) { + return false + } + + return geometries.some((geometry) => { + if (!geometry || !geometry.type || !geometry.coordinates) { + return false + } + if (geometry.type === 'Polygon') { + return isPointInPolygonCoordinates(point, geometry.coordinates) + } + if (geometry.type === 'MultiPolygon') { + return geometry.coordinates.some(polygon => isPointInPolygonCoordinates(point, polygon)) + } + return false + }) +} + +export function getGeoJsonPolygonPaths(geoJson) { + const geometries = collectGeometries(geoJson) + if (!geometries.length) { + return [] + } + + return geometries.flatMap((geometry) => { + if (!geometry || !geometry.type || !geometry.coordinates) { + return [] + } + if (geometry.type === 'Polygon') { + return buildPolygonPaths(geometry.coordinates) + } + if (geometry.type === 'MultiPolygon') { + return geometry.coordinates.flatMap(polygon => buildPolygonPaths(polygon)) + } + return [] + }) +} + +function collectGeometries(geoJson) { + if (!geoJson || typeof geoJson !== 'object') { + return [] + } + + if (geoJson.type === 'Feature') { + return geoJson.geometry ? [geoJson.geometry] : [] + } + + if (geoJson.type === 'FeatureCollection') { + return (geoJson.features || []).flatMap(feature => collectGeometries(feature)) + } + + if (geoJson.type === 'Polygon' || geoJson.type === 'MultiPolygon') { + return [geoJson] + } + + return [] +} + +function isPointInPolygonCoordinates(point, polygonCoordinates) { + if (!Array.isArray(polygonCoordinates) || polygonCoordinates.length === 0) { + return false + } + + const [outerRing, ...holes] = polygonCoordinates + if (!isPointInRing(point, outerRing)) { + return false + } + + return !holes.some(hole => isPointInRing(point, hole)) +} + +function buildPolygonPaths(polygonCoordinates) { + if (!Array.isArray(polygonCoordinates) || polygonCoordinates.length === 0) { + return [] + } + + const [outerRing] = polygonCoordinates + const points = normalizeRingPoints(outerRing) + return points.length >= 3 ? [points] : [] +} + +function normalizeRingPoints(ring) { + if (!Array.isArray(ring)) { + return [] + } + + return ring + .filter(isValidCoordinate) + .map(coordinate => ({ + longitude: Number(coordinate[0]), + latitude: Number(coordinate[1]), + })) +} + +function isPointInRing(point, ring) { + if (!Array.isArray(ring) || ring.length < 3) { + return false + } + + const x = Number(point.longitude) + const y = Number(point.latitude) + let inside = false + + for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { + const current = ring[i] + const previous = ring[j] + if (!isValidCoordinate(current) || !isValidCoordinate(previous)) { + continue + } + + const xi = Number(current[0]) + const yi = Number(current[1]) + const xj = Number(previous[0]) + const yj = Number(previous[1]) + + if (isPointOnSegment(x, y, xi, yi, xj, yj)) { + return true + } + + const intersects = ((yi > y) !== (yj > y)) + && (x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi) + + if (intersects) { + inside = !inside + } + } + + return inside +} + +function isPointOnSegment(px, py, x1, y1, x2, y2) { + const cross = (px - x1) * (y2 - y1) - (py - y1) * (x2 - x1) + if (Math.abs(cross) > 1e-10) { + return false + } + + const dot = (px - x1) * (px - x2) + (py - y1) * (py - y2) + return dot <= 1e-10 +} + +function isValidCoordinate(coordinate) { + return Array.isArray(coordinate) + && coordinate.length >= 2 + && isFiniteNumber(coordinate[0]) + && isFiniteNumber(coordinate[1]) +} + +function isFiniteNumber(value) { + return Number.isFinite(Number(value)) +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 7a6d830..5687467 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -123,7 +123,7 @@ export function getEnvBaseUrl() { // # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。 // TODO @芋艿:这个后续也要调整。 - const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'http://localhost:48080/admin-api' + const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'http://192.168.1.107:48080/admin-api' const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'http://119.96.62.56:7004/admin-api' const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'http://localhost:48080/admin-api'