2 Commits

Author SHA1 Message Date
5ec4b2d818 李红攀:小程序新增农事记录 2026-06-10 14:02:34 +08:00
273772d68a 李红攀:小程序新增农事记录 2026-06-10 10:35:51 +08:00
13 changed files with 1460 additions and 5 deletions

View File

@@ -1 +0,0 @@
npx --no-install commitlint --edit "$1"

1
.husky/commit-msg.bak Normal file
View File

@@ -0,0 +1 @@
npx --no-install commitlint --edit "$1"

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'

24
src/api/agri/batch.ts Normal file
View File

@@ -0,0 +1,24 @@
import { http } from '@/http/http'
export interface AgriBatchVO {
id: number
batchNo: string
plotId: number
plotName: string
cropId: number
cropName: string
farmerId: number
farmerName: string
varietyName: string
sowingDate?: string
transplantDate?: string
expectedHarvestDate?: string
actualHarvestDate?: string
status?: number
totalHarvestWeight?: number
}
/** 获取批次简单列表(用于下拉选择) */
export function getBatchSimpleList() {
return http.get<AgriBatchVO[]>('/agri/batch/simple-list')
}

84
src/api/agri/operation.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 操作类型枚举 */
export const OPERATION_TYPE_OPTIONS = [
{ label: '播种', value: 0 },
{ label: '浇水', value: 1 },
{ label: '施肥', value: 2 },
{ label: '除草', value: 3 },
{ label: '打药', value: 4 },
{ label: '其他', value: 5 },
]
/** 操作类型映射 */
export const OPERATION_TYPE_MAP: Record<number, string> = {
0: '播种',
1: '浇水',
2: '施肥',
3: '除草',
4: '打药',
5: '其他',
}
export interface AgriOperationVO {
id: number
batchId: number
batchNo: string
cropName: string
operationType: number
operationDate: string
operatorName?: string
workContent?: string
imageUrls?: string
remark?: string
createTime?: string
}
export interface AgriOperationPageReqVO extends PageParam {
batchId?: number
operationType?: number
}
export interface AgriOperationFormReqVO {
id?: number
batchId: number
operationType: number
operationDate: string
operatorName?: string
workContent?: string
imageUrls?: string
remark?: string
longitude?: number
latitude?: number
}
/** 获取农事操作分页列表 */
export function getOperationPage(params: AgriOperationPageReqVO) {
return http.get<PageResult<AgriOperationVO>>('/agri/operation/page', params)
}
/** 获取农事操作详情 */
export function getOperation(id: number) {
return http.get<AgriOperationVO>(`/agri/operation/get?id=${id}`)
}
/** 创建农事操作 */
export function createOperation(data: AgriOperationFormReqVO) {
return http.post<number>('/agri/operation/create', data)
}
/** 更新农事操作 */
export function updateOperation(data: AgriOperationFormReqVO) {
return http.put<void>('/agri/operation/update', data)
}
/** 删除农事操作 */
export function deleteOperation(id: number) {
return http.delete<void>(`/agri/operation/delete?id=${id}`)
}
/** 导出农事操作 */
export function exportOperation(params: AgriOperationPageReqVO) {
return http.get<any>('/agri/operation/export', params, { responseType: 'blob' })
}

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

@@ -0,0 +1,697 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="pageTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view class="pb-180rpx">
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group title="农事信息" border>
<!-- 批次选择 -->
<wd-cell title="批次" title-width="180rpx" center>
<wd-picker
v-model="formData.batchId"
:columns="batchColumns"
label=""
placeholder="请选择批次"
@confirm="onBatchConfirm"
/>
</wd-cell>
<!-- 操作日期 -->
<wd-cell title="操作日期" title-width="180rpx" center>
<wd-datetime-picker
v-model="operationDateTimestamp"
type="date"
label=""
placeholder="请选择操作日期"
/>
</wd-cell>
<!-- 操作类型 -->
<wd-cell title="操作类型" title-width="180rpx" center>
<wd-picker
v-model="formData.operationType"
:columns="typeColumns"
label=""
placeholder="请选择类型"
@confirm="onTypeConfirm"
/>
</wd-cell>
<!-- 操作人 -->
<wd-input
v-model="formData.operatorName"
label="操作人"
label-width="180rpx"
placeholder="请输入操作人姓名"
clearable
/>
</wd-cell-group>
<wd-cell-group title="工作详情" border>
<!-- 工作内容 -->
<wd-textarea
v-model="formData.workContent"
label="工作内容"
label-width="180rpx"
placeholder="请输入工作内容"
:maxlength="500"
show-word-limit
clearable
/>
<!-- 备注 -->
<wd-textarea
v-model="formData.remark"
label="备注"
label-width="180rpx"
placeholder="请输入备注"
:maxlength="200"
show-word-limit
clearable
/>
</wd-cell-group>
<wd-cell-group title="图片上传" border>
<view class="px-24rpx py-16rpx">
<wd-upload
v-model:file-list="fileList"
:upload-method="customUpload"
accept="image"
multiple
:limit="9"
@change="onUploadChange"
/>
</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>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
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, 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,
updateOperation,
} from '@/api/agri/operation'
import { getBatchSimpleList } from '@/api/agri/batch'
const props = defineProps<{
id?: number | string
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const pageTitle = computed(() => currentId.value ? '编辑农事记录' : '新增农事记录')
const formLoading = ref(false)
const formRef = ref<FormInstance>()
const batchList = ref<AgriBatchVO[]>([])
const currentId = computed<number | undefined>(() => {
if (props.id === undefined || props.id === null || props.id === '') {
return undefined
}
const parsedId = Number(props.id)
return Number.isFinite(parsedId) ? parsedId : undefined
})
function createDefaultFormData() {
return {
id: undefined as number | undefined,
batchId: undefined as number | undefined,
operationType: undefined as number | undefined,
operatorName: '',
workContent: '',
remark: '',
}
}
/** 操作日期datetime-picker 绑定时间戳) */
const operationDateTimestamp = ref<number>(Date.now())
/** 表单数据 */
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
.filter(batch => batch?.id !== undefined && batch?.id !== null)
.map(batch => ({ value: batch.id, label: `${batch.batchNo} - ${batch.cropName}` })),
)
/** 操作类型下拉列 */
const typeColumns = computed(() =>
Object.entries(OPERATION_TYPE_MAP).map(([value, label]) => ({ value: Number(value), label })),
)
function normalizePickerValue(value: unknown): number | undefined {
const rawValue = Array.isArray(value) ? value[0] : value
if (rawValue === undefined || rawValue === null || rawValue === '') {
return undefined
}
const normalizedValue = Number(rawValue)
return Number.isFinite(normalizedValue) ? normalizedValue : undefined
}
function normalizeDateTimestamp(value?: string) {
if (!value) {
return Date.now()
}
const timestamp = new Date(value).getTime()
return Number.isFinite(timestamp) ? timestamp : Date.now()
}
function buildUploadFileList(imageUrls?: string) {
if (!imageUrls) {
return []
}
return imageUrls
.split(',')
.map(url => url.trim())
.filter(Boolean)
.map((url, index) => ({
status: 'success' as const,
url,
uid: String(index),
isImage: true,
response: JSON.stringify({ data: url }),
}))
}
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)
}
/** 自定义上传方法 */
const customUpload: UploadMethod = (file, formData, options) => {
const uploadTask = uni.uploadFile({
url: `${getEnvBaseUrl()}/infra/file/upload`,
header: options.header,
name: options.name,
fileType: options.fileType,
formData,
filePath: file.url,
success(res) {
if (res.statusCode === options.statusCode) {
options.onSuccess(res, file, formData)
} else {
options.onError({ ...res, errMsg: res.errMsg || '' }, file, formData)
}
},
fail(err) {
options.onError(err, file, formData)
},
})
uploadTask.onProgressUpdate((res) => {
options.onProgress(res, file)
})
}
/** 上传变化回调 */
function onUploadChange({ fileList: currentFileList }: { fileList: UploadFile[] }) {
fileList.value = currentFileList || []
}
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-agri/operation/index')
}
/** 加载批次列表 */
async function loadBatchList() {
try {
const result = await getBatchSimpleList()
batchList.value = Array.isArray(result) ? result : []
} catch {
batchList.value = []
}
}
/** 加载详情 */
async function getDetail() {
if (!currentId.value) return
try {
toast.loading('加载中...')
const data = await getOperation(currentId.value)
const defaultFormData = createDefaultFormData()
const normalizedBatchId = normalizePickerValue(data?.batchId)
const normalizedOperationType = normalizePickerValue(data?.operationType)
formData.value = {
...defaultFormData,
...data,
id: data?.id ?? currentId.value,
batchId: batchColumns.value.some(item => item.value === normalizedBatchId) ? normalizedBatchId : undefined,
operationType: typeColumns.value.some(item => item.value === normalizedOperationType) ? normalizedOperationType : undefined,
operatorName: data?.operatorName || '',
workContent: data?.workContent || '',
remark: data?.remark || '',
}
operationDateTimestamp.value = normalizeDateTimestamp(data?.operationDate)
fileList.value = buildUploadFileList(data?.imageUrls)
} finally {
toast.close()
}
}
/** 构建提交数据 */
function buildSubmitData() {
const submitData: Record<string, any> = {
id: formData.value.id,
batchId: formData.value.batchId,
operationType: formData.value.operationType,
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)
if (!Number.isNaN(date.getTime())) {
submitData.operationDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
}
submitData.imageUrls = fileList.value
.filter(file => file.status === 'success')
.map((file) => {
if (file.response) {
try {
const res = typeof file.response === 'string' ? JSON.parse(file.response) : file.response
return res?.data || file.url
} catch {
return file.url
}
}
return file.url
})
.filter(Boolean)
.join(',')
return submitData
}
/** 提交表单 */
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()
if (currentId.value) {
await updateOperation(submitData)
toast.success('修改成功')
} else {
await createOperation(submitData)
toast.success('新增成功')
}
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(async () => {
formData.value = createDefaultFormData()
fileList.value = []
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>

View File

@@ -0,0 +1,468 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="农事操作记录"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索区域 -->
<view class="px-24rpx pt-24rpx">
<view class="flex items-center">
<view class="flex-1" @click="searchVisible = true">
<wd-search :placeholder="searchPlaceholder" hide-cancel disabled />
</view>
</view>
</view>
<!-- 操作类型快速筛选 -->
<view class="flex items-center overflow-hidden rounded-12rpx bg-white mx-24rpx mb-16rpx shadow-sm">
<view
v-for="tab in typeTabs"
:key="tab.value"
class="flex-1 py-20rpx text-center text-26rpx relative"
:class="activeType === tab.value ? 'text-[#018d71] font-semibold' : 'text-[#666]'"
@click="handleQuickFilter(tab.value)"
>
{{ tab.label }}
<view
v-if="activeType === tab.value"
class="absolute bottom-0 left-1/4 w-1/2 h-4rpx rounded-2rpx bg-[#018d71]"
/>
</view>
</view>
<!-- 农事记录列表 -->
<view class="px-24rpx pb-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-20rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleCardClick(item)"
>
<view class="p-24rpx">
<!-- 头部批次号 + 类型标签 -->
<view class="mb-16rpx flex items-center justify-between">
<view class="text-30rpx text-[#333] font-semibold">
{{ item.batchNo || '-' }}
</view>
<view class="rounded-8rpx px-16rpx py-4rpx text-24rpx bg-[#e8f7f4] text-[#018d71]">
{{ getTypeLabel(item.operationType) }}
</view>
</view>
<!-- 作物 -->
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">作物</text>
<text>{{ item.cropName || '-' }}</text>
</view>
<!-- 操作日期 -->
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">操作日期</text>
<text>{{ formatDate(item.operationDate) }}</text>
</view>
<!-- 操作人 -->
<view class="mb-8rpx flex items-center justify-between text-26rpx text-[#666]">
<text class="text-[#999]">操作人</text>
<text>{{ item.operatorName || '-' }}</text>
</view>
<!-- 工作内容 -->
<view class="mb-12rpx flex items-start justify-between text-26rpx text-[#666]">
<text class="text-[#999] shrink-0">工作内容</text>
<text class="text-[#333] text-right line-clamp-2 ml-16rpx" style="max-width: 500rpx;">{{ item.workContent || '-' }}</text>
</view>
<!-- 图片预览 -->
<view v-if="item.imageUrls" class="flex flex-wrap mt-12rpx gap-8rpx">
<image
v-for="(url, idx) in getImageList(item.imageUrls)"
:key="idx"
:src="url"
mode="aspectFill"
class="image-thumb"
@click.stop="previewImage(item.imageUrls, idx)"
/>
</view>
<!-- 备注 -->
<view v-if="item.remark" class="mt-12rpx text-24rpx text-[#909399]">
备注{{ item.remark }}
</view>
</view>
<!-- 操作按钮 -->
<view class="flex flex-wrap gap-12rpx px-24rpx pb-20rpx" @click.stop>
<wd-button size="small" type="primary" plain @click="handleDetail(item)">
详情
</wd-button>
<wd-button size="small" type="info" plain @click="handleEdit(item)">
编辑
</wd-button>
<wd-button size="small" type="error" plain @click="handleDelete(item)">
删除
</wd-button>
</view>
</view>
<!-- 空状态 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无农事记录" />
</view>
<!-- 加载更多 -->
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
<!-- 新增按钮 -->
<wd-fab
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
<!-- 搜索弹窗 -->
<wd-popup v-model="searchVisible" position="top" @close="searchVisible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">批次</view>
<wd-picker
v-model="searchForm.batchId"
:columns="batchColumns"
label=""
placeholder="请选择批次"
@confirm="onBatchConfirm"
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">操作类型</view>
<wd-picker
v-model="searchForm.operationType"
:columns="typeColumns"
label=""
placeholder="请选择类型"
@confirm="onTypeConfirm"
/>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">重置</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">搜索</wd-button>
</view>
</view>
</wd-popup>
<!-- 详情弹窗 -->
<wd-popup v-model="detailVisible" position="bottom" custom-style="height: 70%;">
<view class="p-24rpx">
<view class="text-32rpx text-[#333] font-semibold mb-24rpx">记录详情</view>
<view v-if="currentItem" class="text-26rpx text-[#666]">
<view class="mb-16rpx flex"><text class="text-[#999] w-160rpx">批次号</text><text>{{ currentItem.batchNo || '-' }}</text></view>
<view class="mb-16rpx flex"><text class="text-[#999] w-160rpx">作物</text><text>{{ currentItem.cropName || '-' }}</text></view>
<view class="mb-16rpx flex"><text class="text-[#999] w-160rpx">操作类型</text><text>{{ getTypeLabel(currentItem.operationType) }}</text></view>
<view class="mb-16rpx flex"><text class="text-[#999] w-160rpx">操作日期</text><text>{{ formatDate(currentItem.operationDate) }}</text></view>
<view class="mb-16rpx flex"><text class="text-[#999] w-160rpx">操作人</text><text>{{ currentItem.operatorName || '-' }}</text></view>
<view class="mb-16rpx flex"><text class="text-[#999] w-160rpx">工作内容</text><text class="flex-1 break-all">{{ currentItem.workContent || '-' }}</text></view>
<view v-if="currentItem.imageUrls" class="mb-16rpx">
<text class="text-[#999]">图片</text>
<view class="flex flex-wrap mt-8rpx gap-8rpx">
<image
v-for="(url, idx) in getImageList(currentItem.imageUrls)"
:key="idx"
:src="url"
mode="aspectFill"
class="image-thumb-lg"
@click="previewImage(currentItem.imageUrls, idx)"
/>
</view>
</view>
<view v-if="currentItem.remark" class="mb-16rpx flex"><text class="text-[#999] w-160rpx">备注</text><text class="flex-1">{{ currentItem.remark }}</text></view>
</view>
<wd-button type="primary" block class="mt-32rpx" @click="detailVisible = false">关闭</wd-button>
</view>
</wd-popup>
</view>
</template>
<script lang="ts" setup>
import type { AgriOperationVO } from '@/api/agri/operation'
import type { AgriBatchVO } from '@/api/agri/batch'
import type { LoadMoreState } from '@/http/types'
import { OPERATION_TYPE_MAP } from '@/api/agri/operation'
import { onReachBottom } from '@dcloudio/uni-app'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getNavbarHeight, navigateBackPlus } from '@/utils'
import {
deleteOperation,
getOperationPage,
} from '@/api/agri/operation'
import { getBatchSimpleList } from '@/api/agri/batch'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const total = ref(0)
const list = ref<AgriOperationVO[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const batchList = ref<AgriBatchVO[]>([])
const queryParams = ref<Record<string, any>>({
pageNo: 1,
pageSize: 10,
})
// 搜索相关
const searchVisible = ref(false)
const searchForm = reactive({
batchId: undefined as number | undefined,
batchIdLabel: '',
operationType: undefined as number | undefined,
operationTypeLabel: '',
})
// 详情弹窗
const detailVisible = ref(false)
const currentItem = ref<AgriOperationVO | null>(null)
// 操作类型快速筛选
const activeType = ref<number | undefined>(undefined)
const typeTabs = computed(() => [
{ label: '全部', value: undefined },
...Object.entries(OPERATION_TYPE_MAP).map(([value, label]) => ({
label,
value: Number(value),
})),
])
/** 批次下拉列 */
const batchColumns = computed(() =>
batchList.value.map(b => ({ value: b.id, label: `${b.batchNo} - ${b.cropName}` })),
)
/** 操作类型下拉列 */
const typeColumns = computed(() =>
Object.entries(OPERATION_TYPE_MAP).map(([value, label]) => ({ value: Number(value), label })),
)
/** 搜索条件 placeholder */
const searchPlaceholder = computed(() => {
const conditions: string[] = []
if (searchForm.batchIdLabel) conditions.push(`批次:${searchForm.batchIdLabel}`)
if (searchForm.operationTypeLabel) conditions.push(`类型:${searchForm.operationTypeLabel}`)
return conditions.length > 0 ? conditions.join(' | ') : '搜索农事记录'
})
/** 格式化日期 */
function formatDate(date?: string | number | Date) {
if (!date) return '-'
if (typeof date === 'number') {
const d = new Date(date)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
if (typeof date === 'object' && date instanceof Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
return String(date).substring(0, 10)
}
/** 获取操作类型标签 */
function getTypeLabel(type?: number) {
if (type === undefined || type === null) return '-'
return OPERATION_TYPE_MAP[type] || '-'
}
/** 获取图片列表 */
function getImageList(imageUrls?: string) {
if (!imageUrls) return []
return imageUrls.split(',').filter(u => u.trim())
}
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 加载批次列表 */
async function loadBatchList() {
try {
batchList.value = await getBatchSimpleList()
} catch {
// ignore
}
}
/** 查询列表 */
async function getList() {
loadMoreState.value = 'loading'
try {
const params: Record<string, any> = { ...queryParams.value }
if (searchForm.batchId) params.batchId = searchForm.batchId
if (searchForm.operationType !== undefined && searchForm.operationType !== null) {
params.operationType = searchForm.operationType
}
const data = await getOperationPage(params)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
}
}
/** 操作类型快速筛选 */
function handleQuickFilter(type?: number) {
activeType.value = type
if (type !== undefined) {
queryParams.value.operationType = type
} else {
delete queryParams.value.operationType
}
queryParams.value.pageNo = 1
list.value = []
getList()
}
/** 搜索 */
function handleSearch() {
searchVisible.value = false
queryParams.value = {
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
}
/** 重置 */
function handleReset() {
activeType.value = undefined
searchForm.batchId = undefined
searchForm.batchIdLabel = ''
searchForm.operationType = undefined
searchForm.operationTypeLabel = ''
searchVisible.value = false
queryParams.value = { pageNo: 1, pageSize: 10 }
list.value = []
getList()
}
/** 批次选择回调 */
function onBatchConfirm({ value }: any) {
searchForm.batchId = value?.[0] || undefined
searchForm.batchIdLabel = value?.[0]
? (batchList.value.find(b => b.id === value[0])?.batchNo || '')
: ''
}
/** 操作类型选择回调 */
function onTypeConfirm({ value }: any) {
searchForm.operationType = value?.[0] ?? undefined
searchForm.operationTypeLabel = value?.[0] !== undefined
? OPERATION_TYPE_MAP[value[0]] || ''
: ''
}
/** 卡片点击 — 打开详情 */
function handleCardClick(item: AgriOperationVO) {
handleDetail(item)
}
/** 详情 */
function handleDetail(item: AgriOperationVO) {
currentItem.value = item
detailVisible.value = true
}
/** 新增 */
function handleAdd() {
uni.navigateTo({
url: '/pages-agri/operation/form/index',
fail: (err) => {
console.error('[DEBUG] navigateTo handleAdd failed:', err)
uni.showToast({ title: '页面跳转失败', icon: 'none' })
},
})
}
/** 编辑 */
function handleEdit(item: AgriOperationVO) {
if (!item?.id) {
toast.show('当前记录缺少 ID')
return
}
uni.navigateTo({
url: `/pages-agri/operation/form/index?id=${item.id}`,
})
}
/** 删除 */
function handleDelete(item: AgriOperationVO) {
uni.showModal({
title: '提示',
content: '确定删除该农事记录?',
confirmColor: '#f5222d',
success: async (res) => {
if (!res.confirm) return
try {
uni.showLoading({ title: '删除中...' })
await deleteOperation(item.id)
toast.success('删除成功')
list.value = []
queryParams.value = { pageNo: 1, pageSize: 10 }
getList()
} catch {
toast.show('删除失败')
} finally {
uni.hideLoading()
}
},
})
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') return
queryParams.value.pageNo++
getList()
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 图片预览 */
function previewImage(imageUrls: string, index: number) {
const urls = getImageList(imageUrls)
uni.previewImage({
urls,
current: index,
})
}
/** 初始化 */
onMounted(() => {
loadBatchList()
getList()
})
</script>
<style lang="scss" scoped>
.image-thumb {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
}
.image-thumb-lg {
width: 140rpx;
height: 140rpx;
border-radius: 8rpx;
}
</style>

View File

@@ -100,6 +100,14 @@ const menuGroupsData: MenuGroup[] = [
iconColor: '#52c41a',
permission: 'agri:biz:harvest',
},
{
key: 'agriOperation',
name: '农事记录',
icon: 'note',
url: '/pages-agri/operation/index',
iconColor: '#fa8c16',
permission: 'agri:operation:query',
},
{
key: 'agriTraceSnapshot',
name: '溯源快照',

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'