李红攀:小程序新增农事记录
This commit is contained in:
4
env/.env
vendored
4
env/.env
vendored
@@ -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`。
|
||||
|
||||
2
env/.env.development
vendored
2
env/.env.development
vendored
@@ -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'
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface AgriOperationFormReqVO {
|
||||
workContent?: string
|
||||
imageUrls?: string
|
||||
remark?: string
|
||||
longitude?: number
|
||||
latitude?: number
|
||||
}
|
||||
|
||||
/** 获取农事操作分页列表 */
|
||||
|
||||
18
src/api/agri/plot.ts
Normal file
18
src/api/agri/plot.ts
Normal file
@@ -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<AgriPlotVO>(`/agri/plot/get?id=${id}`)
|
||||
}
|
||||
@@ -85,6 +85,26 @@
|
||||
/>
|
||||
</view>
|
||||
</wd-cell-group>
|
||||
|
||||
<wd-cell-group title="地块地图" border>
|
||||
<view class="map-section">
|
||||
<view class="map-status-row">
|
||||
<text class="map-status-text" :class="mapStatusClass">{{ mapStatusText }}</text>
|
||||
<wd-button size="small" plain @click="handleRelocate">定位当前位置</wd-button>
|
||||
</view>
|
||||
<map
|
||||
class="plot-map"
|
||||
:longitude="mapCenter.longitude"
|
||||
:latitude="mapCenter.latitude"
|
||||
:scale="mapScale"
|
||||
:markers="mapMarkers"
|
||||
:polygons="mapPolygons"
|
||||
:show-location="true"
|
||||
:enable-zoom="true"
|
||||
:enable-scroll="true"
|
||||
/>
|
||||
</view>
|
||||
</wd-cell-group>
|
||||
</wd-form>
|
||||
</view>
|
||||
|
||||
@@ -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<UploadFile[]>([])
|
||||
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<any[]>([])
|
||||
const currentPlotGeoJson = ref<any | null>(null)
|
||||
const mapReady = ref(false)
|
||||
const locationLoading = ref(false)
|
||||
const locationWatchActive = ref(false)
|
||||
const locationDenied = ref(false)
|
||||
let locationWatchTimer: ReturnType<typeof setInterval> | 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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.map-section {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.map-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.map-status-text {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.map-status-text.is-success {
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.map-status-text.is-warning {
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
.map-status-text.is-neutral {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.plot-map {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
156
src/utils/geo.js
Normal file
156
src/utils/geo.js
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user