李红攀:小程序新增农事记录

This commit is contained in:
2026-06-10 14:02:34 +08:00
parent 273772d68a
commit 5ec4b2d818
7 changed files with 511 additions and 5 deletions

4
env/.env vendored
View File

@@ -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`。

View File

@@ -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'

View File

@@ -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
View 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}`)
}

View File

@@ -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
View 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))
}

View File

@@ -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'