完善项目

This commit is contained in:
xuqiuyun
2025-09-28 17:58:43 +08:00
parent ec3f472641
commit 5b615473e0
59 changed files with 5428 additions and 593 deletions

View File

@@ -5,6 +5,10 @@
"pages/profile/profile",
"pages/login/login",
"pages/cattle/cattle",
"pages/cattle/transfer/transfer",
"pages/cattle/exit/exit",
"pages/cattle/pens/pens",
"pages/cattle/batches/batches",
"pages/device/device",
"pages/device/eartag/eartag",
"pages/device/collar/collar",

View File

@@ -1,6 +1,8 @@
// pages/alert/alert.js
const { get, post } = require('../../utils/api')
const { formatDate, formatTime } = require('../../utils/index')
// 引入预警相关真实接口
const { alertApi } = require('../../services/api')
Page({
data: {
@@ -14,6 +16,10 @@ Page({
pageSize: 20,
hasMore: true,
total: 0,
// 详情弹窗
detailVisible: false,
detailData: null,
detailPairs: [],
alertTypes: [
{ value: 'eartag', label: '耳标预警', icon: '🏷️' },
{ value: 'collar', label: '项圈预警', icon: '📱' },
@@ -28,7 +34,19 @@ Page({
]
},
onLoad() {
onLoad(options) {
// 允许通过URL参数设置默认筛选?type=eartag&status=pending
const { type, status } = options || {}
const validTypes = this.data.alertTypes.map(t => t.value)
const validStatuses = this.data.alertStatuses.map(s => s.value)
if (type && validTypes.includes(type)) {
this.setData({ typeFilter: type })
}
if (status && validStatuses.includes(status)) {
this.setData({ statusFilter: status })
}
this.loadAlertList()
},
@@ -160,34 +178,44 @@ Page({
this.loadAlertList()
},
// 查看预警详情
viewAlertDetail(e) {
// 查看预警详情(在当前页弹窗展示)
async viewAlertDetail(e) {
const id = e.currentTarget.dataset.id
const type = e.currentTarget.dataset.type
let url = ''
switch (type) {
case 'eartag':
url = `/pages/alert/eartag/eartag?id=${id}`
break
case 'collar':
url = `/pages/alert/collar/collar?id=${id}`
break
case 'fence':
url = `/pages/alert/fence/fence?id=${id}`
break
case 'health':
url = `/pages/alert/health/health?id=${id}`
break
default:
wx.showToast({
title: '未知预警类型',
icon: 'none'
})
try {
wx.showLoading({ title: '加载详情...' })
let res
if (type === 'eartag') {
res = await alertApi.getEartagAlertDetail(id)
} else if (type === 'collar') {
res = await alertApi.getCollarAlertDetail(id)
} else {
// 其他类型暂未实现,给出提示
wx.showToast({ title: '该类型详情暂未实现', icon: 'none' })
return
}
const detail = res?.data || res
const detailPairs = this.mapAlertDetail(type, detail)
this.setData({
detailVisible: true,
detailData: detail,
detailPairs
})
} catch (err) {
console.error('加载预警详情失败:', err)
wx.showToast({ title: '加载详情失败', icon: 'none' })
} finally {
wx.hideLoading()
}
wx.navigateTo({ url })
},
// 关闭详情弹窗
closeDetail() {
this.setData({ detailVisible: false, detailData: null, detailPairs: [] })
},
// 处理预警
@@ -386,6 +414,61 @@ Page({
return colorMap[priority] || '#909399'
},
// 映射预警详情字段为键值对用于展示
mapAlertDetail(type, d) {
if (!d) return []
// 通用映射函数
const levelText = {
low: '低',
medium: '中',
high: '高',
urgent: '紧急'
}
const typeText = {
temperature: '温度',
battery: '电量',
movement: '运动',
offline: '离线',
gps: '定位',
eartag: '耳标',
collar: '项圈'
}
const pairs = []
// 基本信息
pairs.push({ label: '预警ID', value: d.id || '-' })
pairs.push({ label: '异常类型', value: typeText[d.alertType] || (d.alertType || '-') })
pairs.push({ label: '异常等级', value: levelText[d.alertLevel] || (d.alertLevel || '-') })
pairs.push({ label: '预警时间', value: d.alertTime ? this.formatTime(d.alertTime) : '-' })
if (type === 'eartag') {
pairs.push({ label: '耳标编号', value: d.eartagNumber || d.deviceName || '-' })
pairs.push({ label: '设备ID', value: d.deviceId || '-' })
pairs.push({ label: '设备状态', value: d.deviceStatus || '-' })
// 传感数据
if (d.temperature !== undefined) pairs.push({ label: '温度(°C)', value: d.temperature })
if (d.battery !== undefined) pairs.push({ label: '电量(%)', value: d.battery })
if (d.gpsSignal) pairs.push({ label: 'GPS信号', value: d.gpsSignal })
if (d.longitude !== undefined) pairs.push({ label: '经度', value: d.longitude })
if (d.latitude !== undefined) pairs.push({ label: '纬度', value: d.latitude })
if (d.movementStatus) pairs.push({ label: '运动状态', value: d.movementStatus })
if (d.dailySteps !== undefined) pairs.push({ label: '今日步数', value: d.dailySteps })
if (d.yesterdaySteps !== undefined) pairs.push({ label: '昨日步数', value: d.yesterdaySteps })
if (d.totalSteps !== undefined) pairs.push({ label: '总步数', value: d.totalSteps })
if (d.description) pairs.push({ label: '描述', value: d.description })
} else if (type === 'collar') {
// 可按需要加入项圈详情字段
pairs.push({ label: '设备ID', value: d.deviceId || '-' })
pairs.push({ label: '设备名称', value: d.deviceName || '-' })
if (d.battery !== undefined) pairs.push({ label: '电量(%)', value: d.battery })
if (d.temperature !== undefined) pairs.push({ label: '温度(°C)', value: d.temperature })
if (d.description) pairs.push({ label: '描述', value: d.description })
}
return pairs
},
// 格式化日期
formatDate(date) {
return formatDate(date)

View File

@@ -153,4 +153,24 @@
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 详情弹窗 -->
<view wx:if="{{detailVisible}}" class="detail-mask" catchtouchmove="true">
<view class="detail-panel">
<view class="detail-header">
<text class="detail-title">预警详情</text>
<text class="detail-close" bindtap="closeDetail">✖</text>
</view>
<view class="detail-body">
<view class="detail-row" wx:for="{{detailPairs}}" wx:key="label">
<text class="detail-label">{{item.label}}</text>
<text class="detail-value">{{item.value}}</text>
</view>
</view>
<view class="detail-footer">
<button class="primary" bindtap="handleAlert" data-id="{{detailData.id}}" data-type="{{typeFilter === 'all' ? (detailData.alertType || 'eartag') : typeFilter}}">处理</button>
<button class="plain" bindtap="ignoreAlert" data-id="{{detailData.id}}" data-type="{{typeFilter === 'all' ? (detailData.alertType || 'eartag') : typeFilter}}">忽略</button>
</view>
</view>
</view>
</view>

View File

@@ -248,6 +248,76 @@
color: #909399;
}
/* 详情弹窗样式 */
.detail-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 999;
}
.detail-panel {
width: 100%;
max-height: 70vh;
background: #fff;
border-top-left-radius: 24rpx;
border-top-right-radius: 24rpx;
overflow: hidden;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.detail-title { font-size: 32rpx; font-weight: 600; color: #303133; }
.detail-close { font-size: 32rpx; color: #909399; }
.detail-body {
padding: 16rpx 24rpx;
max-height: 50vh;
overflow-y: auto;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx dashed #f0f0f0;
}
.detail-label { font-size: 26rpx; color: #606266; }
.detail-value { font-size: 26rpx; color: #303133; }
.detail-footer {
display: flex;
gap: 16rpx;
padding: 24rpx;
border-top: 1rpx solid #f0f0f0;
}
.detail-footer .primary {
flex: 1;
background-color: #3cc51f;
color: #fff;
}
.detail-footer .plain {
flex: 1;
background-color: #f5f5f5;
color: #606266;
}
.no-more {
text-align: center;
padding: 32rpx;

View File

@@ -0,0 +1,273 @@
// pages/cattle/batches/batches.js
const { cattleBatchApi } = require('../../../services/api.js')
Page({
data: {
loading: false,
records: [],
search: '',
page: 1,
pageSize: 10,
total: 0,
totalPages: 1,
pages: []
},
onLoad() {
this.loadData()
},
// 输入
onSearchInput(e) {
this.setData({ search: e.detail.value || '' })
},
onSearchConfirm() {
this.setData({ page: 1 })
this.loadData()
},
onSearch() {
this.setData({ page: 1 })
this.loadData()
},
// 分页按钮
prevPage() {
if (this.data.page > 1) {
this.setData({ page: this.data.page - 1 })
this.loadData()
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.setData({ page: this.data.page + 1 })
this.loadData()
}
},
goToPage(e) {
const page = Number(e.currentTarget.dataset.page)
if (!isNaN(page) && page >= 1 && page <= this.data.totalPages) {
this.setData({ page })
this.loadData()
}
},
// 加载数据
async loadData() {
const { page, pageSize, search } = this.data
this.setData({ loading: true })
try {
// 请求参数,按照需求开启精确匹配
const params = {
page,
pageSize,
search: search || '',
exactMatch: true,
strictMatch: true
}
const res = await cattleBatchApi.getBatches(params)
// 统一解析数据结构
const { list, total, page: curPage, pageSize: size, totalPages } = this.normalizeResponse(res)
let safeList = Array.isArray(list) ? list : []
// 前端二次严格过滤,保证精确查询(名称或编号一模一样)
const kw = (search || '').trim()
if (kw) {
safeList = safeList.filter(it => {
const name = String(it.name || it.batch_name || it.batchName || '').trim()
const code = String(it.code || it.batch_number || it.batchNumber || '').trim()
return name === kw || code === kw
})
}
const mapped = safeList.map(item => this.mapItem(item))
const pages = this.buildPages(totalPages, curPage)
this.setData({
records: mapped,
total: Number(total) || safeList.length,
page: Number(curPage) || page,
pageSize: Number(size) || pageSize,
totalPages: Number(totalPages) || this.calcTotalPages(Number(total) || safeList.length, Number(size) || pageSize),
pages
})
} catch (error) {
console.error('获取批次列表失败:', error)
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
} finally {
this.setData({ loading: false })
}
},
// 解析后端响应,兼容不同结构
normalizeResponse(res) {
// 可能的结构:
// 1) { success: true, data: { list: [...], pagination: { total, page, pageSize, totalPages } } }
// 2) { data: { items: [...], total, page, pageSize, totalPages } }
// 3) 直接返回数组或对象
let dataObj = res?.data ?? res
let list = []
let total = 0
let page = this.data.page
let pageSize = this.data.pageSize
let totalPages = 1
if (Array.isArray(dataObj)) {
list = dataObj
total = dataObj.length
} else if (dataObj && typeof dataObj === 'object') {
// 优先 data.list / data.items
list = dataObj.list || dataObj.items || []
const pag = dataObj.pagination || {}
total = dataObj.total ?? pag.total ?? (Array.isArray(list) ? list.length : 0)
page = dataObj.page ?? pag.page ?? page
pageSize = dataObj.pageSize ?? pag.pageSize ?? pageSize
totalPages = dataObj.totalPages ?? pag.totalPages ?? this.calcTotalPages(total, pageSize)
// 如果整体包了一层 { success: true, data: {...} }
if (!Array.isArray(list) && dataObj.data) {
const inner = dataObj.data
list = inner.list || inner.items || []
const pag2 = inner.pagination || {}
total = inner.total ?? pag2.total ?? (Array.isArray(list) ? list.length : 0)
page = inner.page ?? pag2.page ?? page
pageSize = inner.pageSize ?? pag2.pageSize ?? pageSize
totalPages = inner.totalPages ?? pag2.totalPages ?? this.calcTotalPages(total, pageSize)
}
}
return { list, total, page, pageSize, totalPages }
},
calcTotalPages(total, pageSize) {
const t = Number(total) || 0
const s = Number(pageSize) || 10
return t > 0 ? Math.ceil(t / s) : 1
},
buildPages(totalPages, current) {
const tp = Number(totalPages) || 1
const cur = Number(current) || 1
const arr = []
for (let i = 1; i <= tp; i++) {
arr.push({ num: i, current: i === cur })
}
return arr
},
// 字段中文映射与格式化
mapItem(item) {
const keyMap = {
id: '批次ID',
batch_id: '批次ID',
batchId: '批次ID',
name: '批次名称',
batch_name: '批次名称',
batchName: '批次名称',
code: '批次编号',
batch_number: '批次编号',
batchNumber: '批次编号',
type: '类型',
status: '状态',
enabled: '是否启用',
currentCount: '当前数量',
targetCount: '目标数量',
capacity: '容量',
remark: '备注',
description: '描述',
manager: '负责人',
farm_id: '养殖场ID',
farmId: '养殖场ID',
farmName: '养殖场',
farm: '养殖场对象',
created_at: '创建时间',
updated_at: '更新时间',
create_time: '创建时间',
update_time: '更新时间',
createdAt: '创建时间',
updatedAt: '更新时间',
start_date: '开始日期',
end_date: '结束日期',
startDate: '开始日期',
expectedEndDate: '预计结束日期',
actualEndDate: '实际结束日期'
}
// 头部字段
const statusStr = this.formatStatus(item.status, item.enabled)
const createdAtStr = this.pickTime(item, ['created_at', 'createdAt', 'create_time', 'createTime'])
// 规范化派生字段farm对象拆解
const extra = {}
if (item && typeof item.farm === 'object' && item.farm) {
if (!item.farmName && item.farm.name) extra.farmName = item.farm.name
if (!item.farmId && (item.farm.id || item.farm_id)) extra.farmId = item.farm.id || item.farm_id
}
const merged = { ...item, ...extra }
// 全量字段展示
const pairs = Object.keys(merged || {}).map(k => {
const zh = keyMap[k] || k
const val = this.formatValue(k, merged[k])
return { key: k, keyZh: zh, val }
})
return {
...merged,
statusStr,
createdAtStr,
displayPairs: pairs
}
},
formatStatus(status, enabled) {
if (enabled === true || enabled === 1) return '启用'
if (enabled === false || enabled === 0) return '停用'
if (status === 'enabled') return '启用'
if (status === 'disabled') return '停用'
if (status === 'active') return '活动'
if (status === 'inactive') return '非活动'
if (status === undefined || status === null) return ''
return String(status)
},
pickTime(obj, keys) {
for (const k of keys) {
if (obj && obj[k]) return this.formatDate(obj[k])
}
return ''
},
formatValue(key, val) {
if (val === null || val === undefined) return ''
// 时间类字段统一格式
if (/time|date/i.test(key)) {
return this.formatDate(val)
}
if (typeof val === 'number') return String(val)
if (typeof val === 'boolean') return val ? '是' : '否'
if (Array.isArray(val)) return val.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(', ')
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
},
formatDate(input) {
try {
const d = new Date(input)
if (isNaN(d.getTime())) return String(input)
const pad = (n) => (n < 10 ? '0' + n : '' + n)
const Y = d.getFullYear()
const M = pad(d.getMonth() + 1)
const D = pad(d.getDate())
const h = pad(d.getHours())
const m = pad(d.getMinutes())
return `${Y}-${M}-${D} ${h}:${m}`
} catch (e) {
return String(input)
}
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "牛只批次设置",
"usingComponents": {}
}

View File

@@ -0,0 +1,66 @@
<!-- 牛只批次设置页面 -->
<view class="page-container">
<!-- 顶部搜索区域 -->
<view class="search-bar">
<input class="search-input" placeholder="请输入批次名称或编号(精确匹配)" value="{{search}}" bindinput="onSearchInput" confirm-type="search" bindconfirm="onSearchConfirm" />
<button class="search-btn" bindtap="onSearch">查询</button>
</view>
<!-- 统计与分页信息 -->
<view class="summary-bar">
<text>总数:{{total}}</text>
<text>第 {{page}} / {{totalPages}} 页</text>
</view>
<!-- 批次列表 -->
<scroll-view scroll-y class="list-scroll">
<block wx:if="{{loading}}">
<view class="loading">加载中...</view>
</block>
<block wx:if="{{!loading && records.length === 0}}">
<view class="empty">暂无数据</view>
</block>
<block wx:for="{{records}}" wx:key="id">
<view class="record-card">
<!-- 头部主信息 -->
<view class="record-header">
<view class="title-line">
<text class="name">{{item.name || item.batch_name || item.batchName || '-'}}</text>
<text class="code">编号:{{item.code || item.batch_number || item.batchNumber || '-'}}</text>
</view>
<view class="meta-line">
<text class="status" wx:if="{{item.statusStr}}">状态:{{item.statusStr}}</text>
<text class="time" wx:if="{{item.createdAtStr}}">创建时间:{{item.createdAtStr}}</text>
</view>
</view>
<!-- 详情字段(显示全部返回字段,中文映射) -->
<view class="record-body">
<block wx:for="{{item.displayPairs}}" wx:key="key" wx:for-item="pair">
<view class="pair-row">
<text class="pair-key">{{pair.keyZh}}</text>
<text class="pair-val">{{pair.val}}</text>
</view>
</block>
</view>
</view>
</block>
</scroll-view>
<!-- 分页 -->
<view class="pagination">
<button class="page-btn" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<scroll-view scroll-x class="page-numbers">
<view class="page-items">
<block wx:for="{{pages}}" wx:key="index">
<view class="page-item {{current ? 'active' : ''}}" data-page="{{num}}" bindtap="goToPage">
<text>{{num}}</text>
</view>
</block>
</view>
</scroll-view>
<button class="page-btn" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</view>

View File

@@ -0,0 +1,119 @@
/* 页面容器 */
.page-container {
padding: 12rpx 16rpx;
}
/* 搜索栏 */
.search-bar {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.search-input {
flex: 1;
height: 64rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
padding: 0 12rpx;
background: #fff;
}
.search-btn {
height: 64rpx;
padding: 0 20rpx;
}
/* 概览栏 */
.summary-bar {
display: flex;
justify-content: space-between;
color: #666;
font-size: 26rpx;
margin-bottom: 12rpx;
}
/* 列表滚动区 */
.list-scroll {
max-height: calc(100vh - 280rpx);
}
.loading, .empty {
text-align: center;
color: #999;
padding: 24rpx 0;
}
/* 卡片 */
.record-card {
background: #fff;
border-radius: 12rpx;
padding: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.06);
}
.record-header .title-line {
display: flex;
justify-content: space-between;
margin-bottom: 8rpx;
}
.record-header .name {
font-weight: 600;
font-size: 30rpx;
}
.record-header .code {
color: #333;
}
.record-header .meta-line {
display: flex;
gap: 20rpx;
color: #666;
font-size: 26rpx;
}
.record-body .pair-row {
display: flex;
justify-content: space-between;
padding: 8rpx 0;
border-bottom: 1px dashed #eee;
}
.pair-key {
color: #888;
}
.pair-val {
color: #333;
}
/* 分页 */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
background: #fff;
padding: 12rpx;
border-radius: 8rpx;
}
.page-numbers {
width: 60%;
}
.page-items {
display: flex;
gap: 12rpx;
}
.page-item {
min-width: 56rpx;
height: 56rpx;
line-height: 56rpx;
text-align: center;
border: 1px solid #ddd;
border-radius: 8rpx;
padding: 0 12rpx;
color: #333;
}
.page-item.active {
background: #3cc51f;
color: #fff;
border-color: #3cc51f;
}
.page-btn[disabled] {
opacity: 0.5;
}

View File

@@ -1,5 +1,5 @@
// pages/cattle/cattle.js
const { get } = require('../../utils/api')
const { get, del } = require('../../utils/api')
const { formatDate, formatTime } = require('../../utils/index')
Page({
@@ -8,11 +8,17 @@ Page({
loading: false,
refreshing: false,
searchKeyword: '',
// 设备编号精确查询
deviceNumber: '',
statusFilter: 'all',
page: 1,
pageSize: 20,
// 按需求使用每页10条
pageSize: 10,
hasMore: true,
total: 0
total: 0,
// 分页页码集合
pages: [],
lastPage: 1
},
onLoad(options) {
@@ -51,32 +57,56 @@ Page({
try {
const params = {
// 页码与每页条数的常见别名,提升与后端的兼容性
page: this.data.page,
pageNo: this.data.page,
pageIndex: this.data.page,
current: this.data.page,
pageSize: this.data.pageSize,
size: this.data.pageSize,
limit: this.data.pageSize,
status: this.data.statusFilter === 'all' ? '' : this.data.statusFilter
}
if (this.data.searchKeyword) {
params.search = this.data.searchKeyword
}
// 设备编号精确查询参数(尽可能兼容后端不同命名)
if (this.data.deviceNumber) {
params.deviceNumber = this.data.deviceNumber
params.deviceSn = this.data.deviceNumber
params.deviceId = this.data.deviceNumber
// 部分接口可能支持exact开关
params.exact = true
}
const response = await get('/iot-cattle/public', params)
if (response.success) {
const newList = response.data.list || []
const cattleList = this.data.page === 1 ? newList : [...this.data.cattleList, ...newList]
this.setData({
cattleList,
total: response.data.total || 0,
hasMore: cattleList.length < (response.data.total || 0)
})
} else {
wx.showToast({
title: response.message || '获取数据失败',
icon: 'none'
})
}
// 统一兼容响应结构(尽可能兼容不同后端命名)
const list = (response && response.data && (response.data.list || response.data.records || response.data.items))
|| (response && (response.list || response.records || response.items))
|| []
const totalRaw = (response && response.data && (response.data.total ?? response.data.totalCount ?? response.data.count))
|| (response && (response.total ?? response.totalCount ?? response.count))
|| (response && response.page && response.page.total)
|| 0
const totalPagesOverride = (response && response.data && (response.data.totalPages ?? response.data.pageCount))
|| (response && (response.totalPages ?? response.pageCount))
|| (response && response.page && (response.page.totalPages ?? response.page.pageCount))
|| 0
const total = Number(totalRaw) || 0
const isUnknownTotal = !(Number(totalPagesOverride) > 0) && !(Number(totalRaw) > 0)
console.log('牛只档案接口原始响应:', response)
const mappedList = list.map(this.mapCattleRecord)
console.log('牛只档案字段映射结果(当前页):', mappedList)
const cattleList = this.data.page === 1 ? mappedList : [...this.data.cattleList, ...mappedList]
// 根据总页数判断是否还有更多兼容后端直接返回totalPages/pageCount
// 当后端未返回 total/totalPages 时,使用“本页数据条数 == pageSize”作为是否还有更多的兜底策略。
const totalPages = Math.max(1, Number(totalPagesOverride) || Math.ceil(total / this.data.pageSize))
const hasMore = isUnknownTotal ? (mappedList.length >= this.data.pageSize) : (this.data.page < totalPages)
this.setData({ cattleList, total, hasMore })
console.log('分页计算:', { total, pageSize: this.data.pageSize, totalPages, currentPage: this.data.page, isUnknownTotal })
// 生成分页页码
this.buildPagination(total, totalPages, isUnknownTotal)
} catch (error) {
console.error('获取牛只列表失败:', error)
wx.showToast({
@@ -106,6 +136,13 @@ Page({
})
},
// 设备编号输入(精确查询)
onDeviceInput(e) {
this.setData({
deviceNumber: e.detail.value.trim()
})
},
// 执行搜索
onSearch() {
this.setData({
@@ -116,10 +153,27 @@ Page({
this.loadCattleList()
},
// 执行设备编号精确查询
onDeviceSearch() {
if (!this.data.deviceNumber) {
wx.showToast({ title: '请输入设备编号', icon: 'none' })
return
}
this.setData({
page: 1,
hasMore: true,
cattleList: [],
// 精确查询时不使用模糊关键词
searchKeyword: ''
})
this.loadCattleList()
},
// 清空搜索
onClearSearch() {
this.setData({
searchKeyword: '',
deviceNumber: '',
page: 1,
hasMore: true,
cattleList: []
@@ -245,5 +299,123 @@ Page({
// 格式化时间
formatTime(time) {
return formatTime(time)
},
// 字段中文映射与安全处理
mapCattleRecord(item = {}) {
// 统一时间戳(后端可能返回秒级或毫秒级)
const normalizeTs = (ts) => {
if (ts === null || ts === undefined || ts === '') return ''
if (typeof ts === 'number') {
return ts < 1000000000000 ? ts * 1000 : ts
}
// 字符串数字
const n = Number(ts)
if (!Number.isNaN(n)) return n < 1000000000000 ? n * 1000 : n
return ts
}
// 性别映射(仅做友好展示,保留原值)
const rawSex = item.sex ?? item.gender
const sexText = rawSex === 1 || rawSex === '1' ? '公' : (rawSex === 2 || rawSex === '2' ? '母' : (rawSex ?? '-'))
return {
// 基本标识
id: item.id ?? item.cattleId ?? item._id ?? '',
name: item.name ?? item.cattleName ?? '',
earNumber: item.earNumber ?? item.earNo ?? item.earTag ?? '-',
// 基本属性
breed: item.breed ?? item.breedName ?? item.varieties ?? '-',
strain: item.strain ?? '-',
varieties: item.varieties ?? '-',
cate: item.cate ?? '-',
gender: rawSex ?? '-',
genderText: sexText,
age: item.age ?? item.ageYear ?? '-',
ageInMonths: item.ageInMonths ?? item.ageMonth ?? '-',
// 体重与计算
weight: item.weight ?? item.currentWeight ?? '-',
currentWeight: item.currentWeight ?? '-',
birthWeight: item.birthWeight ?? '-',
sourceWeight: item.sourceWeight ?? '-',
weightCalculateTime: item.weightCalculateTime ? formatDate(normalizeTs(item.weightCalculateTime), 'YYYY-MM-DD HH:mm:ss') : '',
// 来源信息
source: item.source ?? '-',
sourceDay: item.sourceDay ?? '-',
// 关联位置与组织
deviceNumber: item.deviceNumber ?? item.deviceSn ?? item.deviceId ?? '-',
penId: item.penId ?? '-',
penName: item.penName ?? item.barnName ?? '-',
batchId: item.batchId ?? '-',
batchName: item.batchName ?? '-',
farmId: item.farmId ?? '-',
farmName: item.farmName ?? '-',
// 生育与阶段
parity: item.parity ?? '-',
physiologicalStage: item.physiologicalStage ?? '-',
status: item.status ?? item.cattleStatus ?? 'normal',
// 重要日期
birthday: item.birthday ?? item.birthDate ?? item.bornDate ?? item.birthTime ?? '',
birthdayStr: item.birthday || item.birthDate || item.bornDate || item.birthTime
? formatDate(normalizeTs(item.birthday ?? item.birthDate ?? item.bornDate ?? item.birthTime))
: '',
dayOfBirthday: item.dayOfBirthday ?? '-',
intoTime: item.intoTime ?? '',
intoTimeStr: item.intoTime ? formatDate(normalizeTs(item.intoTime)) : ''
}
},
// 生成分页页码,控制展示范围并高亮当前页
buildPagination(total, totalPagesOverride = 0, isUnknownTotal = false) {
const pageSize = this.data.pageSize
const current = this.data.page
// 当总数未知时按已加载的当前页生成页码1..current允许继续“下一页”。
if (isUnknownTotal) {
const pages = Array.from({ length: Math.max(1, current) }, (_, i) => i + 1)
this.setData({ pages, lastPage: current })
return
}
const totalPages = Math.max(1, Number(totalPagesOverride) || Math.ceil(total / pageSize))
let pages = []
const maxVisible = 9
if (totalPages <= maxVisible) {
pages = Array.from({ length: totalPages }, (_, i) => i + 1)
} else {
// 滑动窗口
let start = Math.max(1, current - 4)
let end = Math.min(totalPages, start + maxVisible - 1)
// 保证区间长度
start = Math.max(1, end - maxVisible + 1)
pages = Array.from({ length: end - start + 1 }, (_, i) => start + i)
}
this.setData({ pages, lastPage: totalPages })
},
// 上一页
onPrevPage() {
if (this.data.page <= 1) return
this.setData({ page: this.data.page - 1, cattleList: [] })
this.loadCattleList()
},
// 下一页
onNextPage() {
if (!this.data.hasMore) return
this.setData({ page: this.data.page + 1, cattleList: [] })
this.loadCattleList()
},
// 切换页码
onPageTap(e) {
const targetPage = Number(e.currentTarget.dataset.page)
if (!targetPage || targetPage === this.data.page) return
this.setData({ page: targetPage, cattleList: [] })
this.loadCattleList()
}
})

View File

@@ -5,13 +5,14 @@
<view class="search-input-wrapper">
<input
class="search-input"
placeholder="搜索牛只耳号、姓名..."
placeholder="搜索牛只耳号"
value="{{searchKeyword}}"
bindinput="onSearchInput"
bindconfirm="onSearch"
/>
<text class="search-icon" bindtap="onSearch">🔍</text>
</view>
<text wx:if="{{searchKeyword}}" class="clear-btn" bindtap="onClearSearch">清空</text>
</view>
@@ -73,9 +74,27 @@
<text class="detail-item">耳号: {{item.earNumber}}</text>
<text class="detail-item">品种: {{item.breed || '未知'}}</text>
</view>
<view class="cattle-meta">
<text class="meta-item">年龄: {{item.age || '未知'}}岁</text>
<text class="meta-item">体重: {{item.weight || '未知'}}kg</text>
<!-- 扩展详情,展示全部字段 -->
<view class="cattle-extra">
<view class="extra-row"><text class="extra-label">性别:</text><text class="extra-value">{{item.genderText}}</text></view>
<view class="extra-row"><text class="extra-label">血统:</text><text class="extra-value">{{item.strain || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">品系:</text><text class="extra-value">{{item.varieties || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">品类:</text><text class="extra-value">{{item.cate || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">年龄(月):</text><text class="extra-value">{{item.ageInMonths || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">出生日期:</text><text class="extra-value">{{item.birthdayStr || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">出生体重(kg):</text><text class="extra-value">{{item.birthWeight || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">当前体重(kg):</text><text class="extra-value">{{item.currentWeight || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">来源:</text><text class="extra-value">{{item.source || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">来源天数:</text><text class="extra-value">{{item.sourceDay || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">来源体重(kg):</text><text class="extra-value">{{item.sourceWeight || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">批次:</text><text class="extra-value">{{item.batchName || '-'}}(ID:{{item.batchId || '-'}})</text></view>
<view class="extra-row"><text class="extra-label">农场:</text><text class="extra-value">{{item.farmName || '-'}}(ID:{{item.farmId || '-'}})</text></view>
<view class="extra-row"><text class="extra-label">栏舍:</text><text class="extra-value">{{item.penName || '-'}}(ID:{{item.penId || '-'}})</text></view>
<view class="extra-row"><text class="extra-label">进场日期:</text><text class="extra-value">{{item.intoTimeStr || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">胎次:</text><text class="extra-value">{{item.parity || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">生理阶段:</text><text class="extra-value">{{item.physiologicalStage || '-'}} </text></view>
<view class="extra-row"><text class="extra-label">体重计算时间:</text><text class="extra-value">{{item.weightCalculateTime || '-'}} </text></view>
</view>
</view>
@@ -112,6 +131,21 @@
</view>
</view>
<!-- 分页导航 -->
<view wx:if="{{cattleList.length > 0}}" class="pagination">
<view class="page-item {{page <= 1 ? 'disabled' : ''}}" bindtap="onPrevPage">上一页</view>
<view class="page-item" data-page="1" bindtap="onPageTap">首页</view>
<view
wx:for="{{pages}}"
wx:key="*this"
class="page-item {{item === page ? 'active' : ''}}"
data-page="{{item}}"
bindtap="onPageTap"
>{{item}}</view>
<view class="page-item" data-page="{{lastPage}}" bindtap="onPageTap">末页</view>
<view class="page-item {{page >= lastPage ? 'disabled' : ''}}" bindtap="onNextPage">下一页</view>
</view>
<!-- 添加按钮 -->
<view class="fab" bindtap="addCattle">
<text class="fab-icon">+</text>

View File

@@ -139,6 +139,26 @@
margin-bottom: 2rpx;
}
/* 扩展详情样式 */
.cattle-extra {
margin-top: 8rpx;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6rpx 12rpx;
}
.extra-row {
display: flex;
align-items: center;
font-size: 22rpx;
}
.extra-label {
color: #909399;
margin-right: 8rpx;
}
.extra-value {
color: #303133;
}
.cattle-status {
display: flex;
flex-direction: column;
@@ -223,6 +243,37 @@
color: #c0c4cc;
}
/* 分页导航 */
.pagination {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
align-items: center;
justify-content: center;
padding: 20rpx 16rpx 40rpx;
}
.page-item {
min-width: 60rpx;
padding: 12rpx 20rpx;
border-radius: 8rpx;
background-color: #f5f5f5;
color: #606266;
font-size: 26rpx;
text-align: center;
}
.page-item.active {
background-color: #3cc51f;
color: #ffffff;
}
/* 分页禁用态 */
.page-item.disabled {
opacity: 0.5;
pointer-events: none;
}
.fab {
position: fixed;
right: 32rpx;

View File

@@ -0,0 +1,258 @@
// pages/cattle/exit/exit.js
const { cattleExitApi } = require('../../../services/api')
const { formatDate, formatTime } = require('../../../utils/index')
Page({
data: {
records: [],
loading: false,
searchEarNumber: '',
page: 1,
pageSize: 10,
hasMore: true,
total: 0,
pages: [],
lastPage: 1
},
onLoad() {
this.loadRecords()
},
// 规范化时间戳,兼容秒/毫秒
normalizeTs(ts) {
if (!ts) return ''
const n = Number(ts)
if (!Number.isFinite(n)) return ''
return n < 10_000_000_000 ? n * 1000 : n
},
// 兼容数值时间戳与 ISO 字符串
formatAnyTime(v) {
if (!v) return ''
if (typeof v === 'number') {
const n = this.normalizeTs(v)
return n ? `${formatDate(n)} ${formatTime(n)}` : ''
}
if (typeof v === 'string') {
const p = Date.parse(v)
if (!Number.isNaN(p)) {
return `${formatDate(p)} ${formatTime(p)}`
}
return v
}
return ''
},
async loadRecords() {
this.setData({ loading: true })
const { page, pageSize, searchEarNumber } = this.data
try {
// 支持分页与耳号精确查询search
const resp = await cattleExitApi.getExitRecords({ page, pageSize, search: searchEarNumber })
let list = []
let total = 0
let totalPagesOverride = 0
let isUnknownTotal = false
if (Array.isArray(resp)) {
list = resp
isUnknownTotal = true
} else if (resp && typeof resp === 'object') {
const data = resp.data || resp
list = data.list || data.records || data.items || []
total = Number(data.total || data.count || data.totalCount || 0)
if (total > 0) {
totalPagesOverride = Math.ceil(total / pageSize)
} else {
isUnknownTotal = true
}
}
let mapped = (list || []).map(this.mapRecord.bind(this))
// 前端再次做耳号精确过滤,确保“精确查询”要求
if (searchEarNumber && typeof searchEarNumber === 'string') {
const kw = searchEarNumber.trim()
if (kw) {
mapped = mapped.filter(r => String(r.earNumber) === kw)
// 在精确查询场景下通常结果有限,分页根据当前页构造
isUnknownTotal = true
total = mapped.length
totalPagesOverride = 1
}
}
const hasMore = isUnknownTotal ? (mapped.length >= pageSize) : (page < Math.max(1, totalPagesOverride))
this.setData({
records: mapped,
total: total || 0,
hasMore
})
this.buildPagination(total || 0, totalPagesOverride, isUnknownTotal)
} catch (error) {
console.error('获取离栏记录失败:', error)
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
} finally {
this.setData({ loading: false })
}
},
// 字段映射(显示全部返回字段),并尽量做中文映射与嵌套对象处理
mapRecord(item = {}) {
const normalize = (v) => (v === null || v === undefined || v === '') ? '-' : v
// 主展示字段(可能包含嵌套对象)
const earNumber = item.earNumber || item.earNo || item.earTag || '-'
const exitPen = (item.originalPen && (item.originalPen.name || item.originalPen.code))
|| (item.pen && (item.pen.name || item.pen.code))
|| item.originalPenName || item.penName || item.barnName || item.pen || item.barn || '-'
const exitTime = item.exitDate || item.exitTime || item.exitAt || item.createdAt || item.createTime || item.time || item.created_at || ''
const labelMap = {
id: '记录ID',
recordId: '记录编号',
animalId: '动物ID', cattleId: '牛只ID',
earNumber: '耳号', earNo: '耳号', earTag: '耳号',
penName: '栏舍', barnName: '栏舍', pen: '栏舍', barn: '栏舍', penId: '栏舍ID',
originalPen: '原栏舍', originalPenId: '原栏舍ID',
exitDate: '离栏时间', exitTime: '离栏时间', exitAt: '离栏时间', time: '离栏时间',
createdAt: '创建时间', createTime: '创建时间', updatedAt: '更新时间', updateTime: '更新时间',
created_at: '创建时间', updated_at: '更新时间',
operator: '操作人', operatorId: '操作人ID', handler: '经办人',
remark: '备注', note: '备注', reason: '原因', exitReason: '离栏原因', status: '状态',
disposalMethod: '处置方式', destination: '去向',
batchId: '批次ID', batchName: '批次',
farmId: '农场ID', farmName: '农场',
deviceNumber: '设备编号', deviceSn: '设备编号', deviceId: '设备ID',
weight: '体重(kg)'
}
const displayFields = []
Object.keys(item).forEach((key) => {
let value = item[key]
// 嵌套对象处理
if (key === 'farm' && value && typeof value === 'object') {
displayFields.push({ key: 'farm.name', label: '农场', value: normalize(value.name) })
if (value.id !== undefined) displayFields.push({ key: 'farm.id', label: '农场ID', value: normalize(value.id) })
return
}
if (key === 'pen' && value && typeof value === 'object') {
displayFields.push({ key: 'pen.name', label: '栏舍', value: normalize(value.name) })
if (value.code) displayFields.push({ key: 'pen.code', label: '栏舍编码', value: normalize(value.code) })
if (value.id !== undefined) displayFields.push({ key: 'pen.id', label: '栏舍ID', value: normalize(value.id) })
return
}
if (key === 'originalPen' && value && typeof value === 'object') {
displayFields.push({ key: 'originalPen.name', label: '原栏舍', value: normalize(value.name) })
if (value.code) displayFields.push({ key: 'originalPen.code', label: '原栏舍编码', value: normalize(value.code) })
if (value.id !== undefined) displayFields.push({ key: 'originalPen.id', label: '原栏舍ID', value: normalize(value.id) })
return
}
// 时间字段统一格式化
if (/time|At|Date$|_at$/i.test(key)) {
value = this.formatAnyTime(value) || '-'
}
// 将 ID 字段展示为对应的名称
if (key === 'farmId') {
displayFields.push({ key: 'farmId', label: '农场', value: normalize((item.farm && item.farm.name) || value) })
return
}
if (key === 'penId') {
displayFields.push({ key: 'penId', label: '栏舍', value: normalize((item.pen && item.pen.name) || value) })
return
}
displayFields.push({
key,
label: labelMap[key] || key,
value: normalize(value)
})
})
const headerKeys = [
'earNumber', 'earNo', 'earTag',
'penName', 'barnName', 'pen', 'barn', 'pen.name', 'originalPen.name',
'exitDate', 'exitTime', 'exitAt', 'time', 'createdAt', 'created_at', 'farm.name',
'recordId', 'status'
]
const details = displayFields.filter(f => !headerKeys.includes(f.key))
return {
id: item.id || item._id || '-',
recordId: item.recordId || '-',
status: item.status || '-',
earNumber,
pen: exitPen,
exitTimeStr: this.formatAnyTime(exitTime) || '-',
operator: item.operator || item.handler || '-',
remark: item.remark || item.note || '-',
batchName: item.batchName || '-',
farmName: (item.farm && item.farm.name) || item.farmName || '-',
deviceNumber: item.deviceNumber || item.deviceSn || item.deviceId || '-',
details,
raw: item
}
},
// 搜索输入(耳号精确查询)
onSearchInput(e) {
this.setData({ searchEarNumber: e.detail.value || '' })
},
onSearch() {
this.setData({ page: 1 })
this.loadRecords()
},
onClearSearch() {
this.setData({ searchEarNumber: '', page: 1 })
this.loadRecords()
},
// 分页构造与交互
buildPagination(total, totalPagesOverride = 0, isUnknownTotal = false) {
const pageSize = this.data.pageSize
const current = this.data.page
if (isUnknownTotal) {
const pages = Array.from({ length: Math.max(1, current) }, (_, i) => i + 1)
this.setData({ pages, lastPage: current })
return
}
const totalPages = Math.max(1, Number(totalPagesOverride) || Math.ceil(total / pageSize))
let pages = []
const maxVisible = 9
if (totalPages <= maxVisible) {
pages = Array.from({ length: totalPages }, (_, i) => i + 1)
} else {
let start = Math.max(1, current - 4)
let end = Math.min(totalPages, start + maxVisible - 1)
start = Math.max(1, end - maxVisible + 1)
pages = Array.from({ length: end - start + 1 }, (_, i) => start + i)
}
this.setData({ pages, lastPage: totalPages })
},
onPrevPage() {
if (this.data.page <= 1) return
this.setData({ page: this.data.page - 1, records: [] })
this.loadRecords()
},
onNextPage() {
if (!this.data.hasMore) return
this.setData({ page: this.data.page + 1, records: [] })
this.loadRecords()
},
onPageTap(e) {
const p = Number(e.currentTarget.dataset.page)
if (!Number.isFinite(p)) return
if (p === this.data.page) return
this.setData({ page: p, records: [] })
this.loadRecords()
}
})

View File

@@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@@ -0,0 +1,51 @@
<view class="page">
<view class="header">
<view class="title">牛只管理 - 离栏记录</view>
<view class="search-box">
<input class="search-input" placeholder="输入耳号精确查询" value="{{searchEarNumber}}" bindinput="onSearchInput" />
<button class="search-btn" bindtap="onSearch">查询</button>
<button class="clear-btn" bindtap="onClearSearch">清空</button>
</view>
</view>
<view class="list" wx:if="{{records && records.length > 0}}">
<block wx:for="{{records}}" wx:key="id">
<view class="card">
<view class="card-header">
<view class="left">
<view class="main">耳号:{{item.earNumber}}</view>
<view class="sub">离栏栏舍:{{item.pen}}</view>
</view>
<view class="right">
<view class="meta">状态:{{item.status ? item.status : '-'}} | 记录编号:{{item.recordId ? item.recordId : '-'}} | 农场:{{item.farmName ? item.farmName : '-'}} </view>
<view class="time">离栏时间:{{item.exitTimeStr}}</view>
</view>
</view>
<view class="card-body">
<view class="details">
<block wx:for="{{item.details}}" wx:key="key">
<view class="row">
<text class="label">{{item.label}}</text>
<text class="value">{{item.value}}</text>
</view>
</block>
</view>
</view>
</view>
</block>
</view>
<view class="empty" wx:else>暂无数据</view>
<view class="pagination">
<button class="prev" bindtap="onPrevPage" disabled="{{page<=1}}">上一页</button>
<block wx:for="{{pages}}" wx:key="*this">
<view class="page-item {{page==item ? 'active' : ''}}" data-page="{{item}}" bindtap="onPageTap">{{item}}</view>
</block>
<button class="next" bindtap="onNextPage" disabled="{{!hasMore}}">下一页</button>
</view>
<view class="footer">
<view class="tips">总数:{{total}};当前页:{{page}} / {{lastPage}}</view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
</view>
</view>

View File

@@ -0,0 +1,32 @@
page {
background: #f7f8fa;
}
.header { padding: 12px; background: #fff; border-bottom: 1px solid #eee; }
.title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.search-box { display: flex; gap: 8px; }
.search-input { flex: 1; border: 1px solid #ddd; padding: 6px 8px; border-radius: 4px; }
.search-btn, .clear-btn { padding: 6px 12px; border-radius: 4px; background: #1677ff; color: #fff; }
.clear-btn { background: #999; }
.list { padding: 12px; }
.card { background: #fff; border-radius: 8px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); margin-bottom: 12px; }
.card-header { display: flex; justify-content: space-between; border-bottom: 1px dashed #eee; padding-bottom: 8px; margin-bottom: 8px; }
.left .main { font-size: 16px; font-weight: bold; }
.left .sub { font-size: 14px; color: #666; margin-top: 4px; }
.right { text-align: right; }
.right .meta { font-size: 12px; color: #333; }
.right .time { font-size: 12px; color: #666; margin-top: 4px; }
.details .row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed #f0f0f0; }
.details .row:last-child { border-bottom: none; }
.label { color: #666; }
.value { color: #111; font-weight: 500; }
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; }
.page-item { padding: 4px 10px; border-radius: 4px; background: #fff; border: 1px solid #ddd; }
.page-item.active { background: #1677ff; color: #fff; border-color: #1677ff; }
.prev, .next { background: #fff; border: 1px solid #ddd; padding: 6px 12px; border-radius: 4px; }
.prev[disabled], .next[disabled] { opacity: 0.5; }
.footer { padding: 12px; text-align: center; color: #666; }
.loading { margin-top: 8px; }

View File

@@ -0,0 +1,285 @@
// pages/cattle/pens/pens.js - 栏舍设置页面
const { cattlePenApi } = require('../../../services/api')
Page({
data: {
loading: false,
records: [],
displayRecords: [],
page: 1,
pageSize: 10,
total: 0,
pages: [],
currentPage: 1,
searchName: '',
hasMore: false,
headerKeys: ['name', 'code', 'farmName', 'status', 'capacity', 'createdAtStr'],
paginationWindow: 7
},
onLoad() {
this.loadRecords()
},
onPullDownRefresh() {
this.setData({ page: 1 })
this.loadRecords().finally(() => wx.stopPullDownRefresh())
},
onReachBottom() {
const { page, hasMore } = this.data
if (hasMore) {
this.setData({ page: page + 1 })
this.loadRecords()
}
},
// 统一时间格式化
formatAnyTime(val) {
if (!val) return ''
try {
const d = typeof val === 'string' ? new Date(val) : new Date(val)
if (isNaN(d.getTime())) return String(val)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${dd} ${hh}:${mi}`
} catch (e) {
return String(val)
}
},
// 字段中文映射
getLabelMap() {
return {
id: '栏舍ID',
name: '栏舍名称',
penName: '栏舍名称',
code: '栏舍编码',
penId: '栏舍编号',
barnId: '所在牛舍ID',
barn: '所在牛舍',
farmId: '养殖场ID',
farm_id: '养殖场ID',
farm: '养殖场',
capacity: '容量',
status: '状态',
type: '类型',
location: '位置',
area: '面积',
currentCount: '当前数量',
manager: '负责人',
contact: '联系电话',
phone: '联系电话',
remark: '备注',
note: '备注',
createdAt: '创建时间',
created_at: '创建时间',
updatedAt: '更新时间',
updated_at: '更新时间',
createdTime: '创建时间',
updatedTime: '更新时间',
isActive: '是否启用',
enabled: '是否启用',
disabled: '是否禁用'
}
},
// 映射单条记录为展示结构
mapRecord(raw) {
const labelMap = this.getLabelMap()
const rec = { ...raw }
// 头部主信息
const name = rec.name || rec.penName || rec.pen || rec.title || ''
const code = rec.code || rec.penCode || rec.number || rec.no || ''
const capacity = rec.capacity || rec.size || rec.maxCapacity || ''
const status = rec.status || rec.state || rec.enable || rec.enabled
let farmName = ''
if (rec.farm && typeof rec.farm === 'object') {
farmName = rec.farm.name || rec.farm.title || rec.farm.code || ''
} else if (rec.farmName) {
farmName = rec.farmName
}
const createdAtStr = this.formatAnyTime(
rec.createdAt || rec.createdTime || rec.createTime || rec.created_at
)
// 详情区:将所有可用字段转为 label + value
const displayFields = []
const pushField = (key, value) => {
if (value === undefined || value === null || value === '') return
const label = labelMap[key] || key
// 时间字段统一格式(兼容驼峰/下划线)
const isTimeKey = /(time|Time|createdAt|updatedAt|created_at|updated_at|createTime|updateTime|create_time|update_time)/i.test(key)
const v = isTimeKey ? this.formatAnyTime(value) : value
displayFields.push({ key, label, value: v })
}
// 扁平字段
Object.keys(rec).forEach(k => {
const val = rec[k]
if (typeof val !== 'object' || val === null) {
// 避免重复添加已在头部展示的字段
if (['name', 'penName', 'code', 'penCode'].includes(k)) return
pushField(k, val)
}
})
// 嵌套对象farm、barn 等
const pushNested = (obj, prefixLabel) => {
if (!obj || typeof obj !== 'object') return
const nameV = obj.name || obj.title || obj.code
const idV = obj.id
if (nameV) displayFields.push({ key: `${prefixLabel}Name`, label: `${prefixLabel}名称`, value: nameV })
if (obj.code) displayFields.push({ key: `${prefixLabel}Code`, label: `${prefixLabel}编码`, value: obj.code })
if (idV) displayFields.push({ key: `${prefixLabel}Id`, label: `${prefixLabel}ID`, value: idV })
}
pushNested(rec.farm, '养殖场')
pushNested(rec.barn, '所在牛舍')
return {
name,
code,
capacity,
status,
farmName,
createdAtStr,
displayFields
}
},
// 构建分页页码
buildPagination(total, pageSize, currentPage) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const windowSize = this.data.paginationWindow || 7
let start = Math.max(1, currentPage - Math.floor(windowSize / 2))
let end = Math.min(totalPages, start + windowSize - 1)
// 如果窗口不足,向前回补
start = Math.max(1, end - windowSize + 1)
const pages = []
for (let i = start; i <= end; i++) pages.push(i)
return { pages, totalPages }
},
// 加载数据
async loadRecords() {
const { page, pageSize, searchName } = this.data
this.setData({ loading: true })
try {
const params = { page, pageSize }
// 后端使用 search 参数进行关键词搜索,这里与后端保持一致
if (searchName && searchName.trim()) params.search = searchName.trim()
const res = await cattlePenApi.getPens(params)
// 统一规范化响应结构,避免 list.map 报错
const normalize = (response) => {
// 纯数组直接返回
if (Array.isArray(response)) {
return { list: response, total: response.length, pagination: { total: response.length } }
}
// 优先从 data 节点取值
const dataNode = response?.data !== undefined ? response.data : response
// 提取列表
let list = []
if (Array.isArray(dataNode)) {
list = dataNode
} else if (Array.isArray(dataNode?.list)) {
list = dataNode.list
} else if (Array.isArray(dataNode?.items)) {
list = dataNode.items
} else if (Array.isArray(response?.list)) {
list = response.list
} else if (Array.isArray(response?.items)) {
list = response.items
} else {
list = []
}
// 提取分页与总数
const pagination = dataNode?.pagination || response?.pagination || {}
const total = (
pagination?.total ??
dataNode?.total ??
response?.total ??
(typeof dataNode?.count === 'number' ? dataNode.count : undefined) ??
(typeof response?.count === 'number' ? response.count : undefined) ??
list.length
)
return { list, total, pagination }
}
const { list, total, pagination } = normalize(res)
// 映射展示结构
const safeList = Array.isArray(list) ? list : []
let mapped = safeList.map(item => this.mapRecord(item))
// 前端严格精确查询(避免远程不支持或模糊匹配)
if (searchName && searchName.trim()) {
const kw = searchName.trim()
mapped = mapped.filter(r => String(r.name) === kw)
}
const { pages, totalPages } = this.buildPagination(total, pageSize, page)
const hasMore = page < totalPages
this.setData({
records: safeList,
displayRecords: mapped,
total,
pages,
currentPage: page,
hasMore,
loading: false
})
} catch (error) {
console.error('获取栏舍数据失败:', error)
wx.showToast({ title: error.message || '获取失败', icon: 'none' })
this.setData({ loading: false })
}
},
// 搜索输入变更
onSearchInput(e) {
const v = e.detail?.value ?? ''
this.setData({ searchName: v })
},
// 执行搜索(精确)
onSearchConfirm() {
this.setData({ page: 1 })
this.loadRecords()
},
// 切换页码
onPageTap(e) {
const p = Number(e.currentTarget.dataset.page)
if (!p || p === this.data.currentPage) return
this.setData({ page: p })
this.loadRecords()
},
onPrevPage() {
const { currentPage } = this.data
if (currentPage > 1) {
this.setData({ page: currentPage - 1 })
this.loadRecords()
}
},
onNextPage() {
const { currentPage, pages } = this.data
const max = pages.length ? Math.max(...pages) : currentPage
if (currentPage < max) {
this.setData({ page: currentPage + 1 })
this.loadRecords()
}
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "栏舍设置",
"usingComponents": {}
}

View File

@@ -0,0 +1,51 @@
<view class="page">
<!-- 头部与搜索栏 -->
<view class="header">
<text class="title">牛只管理 · 栏舍设置</text>
<view class="search-bar">
<input class="search-input" placeholder="输入栏舍名称(精确)" bindinput="onSearchInput" confirm-type="search" bindconfirm="onSearchConfirm" value="{{searchName}}" />
<button class="search-btn" bindtap="onSearchConfirm">查询</button>
</view>
</view>
<!-- 列表 -->
<view class="list">
<block wx:for="{{displayRecords}}" wx:key="index">
<view class="card">
<view class="card-header">
<view class="main">
<text class="name">栏舍:{{item.name || '-'}}</text>
<text class="code">编码:{{item.code || '-'}}</text>
</view>
<view class="meta">
<text>养殖场:{{item.farmName || '-'}} </text>
<text>状态:{{item.status || '-'}} </text>
<text>容量:{{item.capacity || '-'}} </text>
</view>
<view class="time">
<text>创建时间:{{item.createdAtStr || '-'}} </text>
</view>
</view>
<view class="card-body">
<block wx:for="{{item.displayFields}}" wx:key="key">
<view class="row">
<text class="label">{{item.label}}</text>
<text class="value">{{item.value}}</text>
</view>
</block>
</view>
</view>
</block>
<view wx:if="{{!displayRecords || displayRecords.length === 0}}" class="empty">暂无数据</view>
</view>
<!-- 分页 -->
<view class="pagination" wx:if="{{pages && pages.length}}">
<button class="pager-btn" bindtap="onPrevPage" disabled="{{currentPage<=1}}">上一页</button>
<block wx:for="{{pages}}" wx:key="page">
<button class="page-item {{currentPage === item ? 'active' : ''}}" data-page="{{item}}" bindtap="onPageTap">{{item}}</button>
</block>
<button class="pager-btn" bindtap="onNextPage" disabled="{{!hasMore}}">下一页</button>
</view>
</view>

View File

@@ -0,0 +1,27 @@
/* 栏舍设置页面样式 */
.page { padding: 12px; background: #f7f8fa; min-height: 100vh; }
.header { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.title { font-size: 16px; font-weight: 600; color: #333; }
.search-bar { display: flex; gap: 8px; }
.search-input { flex: 1; border: 1px solid #ddd; border-radius: 6px; padding: 8px 10px; background: #fff; }
.search-btn { padding: 8px 12px; background: #3cc51f; color: #fff; border-radius: 6px; }
.list { display: flex; flex-direction: column; gap: 12px; }
.card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); padding: 12px; }
.card-header { display: flex; flex-direction: column; gap: 6px; border-bottom: 1px dashed #eee; padding-bottom: 8px; }
.main { display: flex; gap: 10px; align-items: baseline; }
.name { font-size: 16px; font-weight: 600; color: #222; }
.code { font-size: 14px; color: #666; }
.meta { display: flex; flex-wrap: wrap; gap: 10px; color: #666; font-size: 13px; }
.time { color: #999; font-size: 12px; }
.card-body { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.row { display: flex; gap: 8px; }
.label { color: #666; min-width: 84px; text-align: right; }
.value { color: #333; flex: 1; }
.pagination { display: flex; gap: 6px; justify-content: center; align-items: center; margin-top: 14px; }
.pager-btn { background: #f0f0f0; color: #333; border-radius: 6px; padding: 6px 10px; }
.page-item { background: #fff; color: #333; border: 1px solid #ddd; border-radius: 6px; padding: 6px 10px; }
.page-item.active { background: #3cc51f; border-color: #3cc51f; color: #fff; }
.empty { text-align: center; color: #999; padding: 20px 0; }

View File

@@ -0,0 +1,257 @@
// pages/cattle/transfer/transfer.js
const { cattleTransferApi } = require('../../../services/api')
const { formatDate, formatTime } = require('../../../utils/index')
Page({
data: {
records: [],
loading: false,
searchEarNumber: '',
page: 1,
pageSize: 10,
hasMore: true,
total: 0,
pages: [],
lastPage: 1
},
onLoad() {
this.loadRecords()
},
// 规范化时间戳,兼容秒/毫秒
normalizeTs(ts) {
if (!ts) return ''
const n = Number(ts)
if (!Number.isFinite(n)) return ''
return n < 10_000_000_000 ? n * 1000 : n
},
// 兼容数值时间戳与 ISO 字符串
formatAnyTime(v) {
if (!v) return ''
if (typeof v === 'number') {
const n = this.normalizeTs(v)
return n ? `${formatDate(n)} ${formatTime(n)}` : ''
}
if (typeof v === 'string') {
const p = Date.parse(v)
if (!Number.isNaN(p)) {
return `${formatDate(p)} ${formatTime(p)}`
}
// 非法字符串,直接返回原值
return v
}
return ''
},
async loadRecords() {
this.setData({ loading: true })
const { page, pageSize, searchEarNumber } = this.data
try {
// 使用统一的列表接口,并传递 search 参数以满足“耳号精确查询”需求
const resp = await cattleTransferApi.getTransferRecords({ page, pageSize, search: searchEarNumber })
let list = []
let total = 0
let totalPagesOverride = 0
let isUnknownTotal = false
if (Array.isArray(resp)) {
list = resp
// 未返回总数时,认为总数未知;是否有更多由本页数量是否满页来推断
isUnknownTotal = true
} else if (resp && typeof resp === 'object') {
// 常见结构兼容:{ list, total } 或 { data: { list, total } } 或 { records, total }
const data = resp.data || resp
list = data.list || data.records || data.items || []
total = Number(data.total || data.count || data.totalCount || 0)
if (total > 0) {
totalPagesOverride = Math.ceil(total / pageSize)
} else {
isUnknownTotal = true
}
}
const mapped = (list || []).map(this.mapRecord.bind(this))
const hasMore = isUnknownTotal ? (mapped.length >= pageSize) : (page < Math.max(1, totalPagesOverride))
this.setData({
records: mapped,
total: total || 0,
hasMore
})
this.buildPagination(total || 0, totalPagesOverride, isUnknownTotal)
} catch (error) {
console.error('获取转栏记录失败:', error)
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
} finally {
this.setData({ loading: false })
}
},
// 字段映射(尽量覆盖常见后端字段命名),并保留原始字段用于“全部显示”
mapRecord(item = {}) {
const normalize = (v) => (v === null || v === undefined || v === '') ? '-' : v
// 头部主展示字段(兼容嵌套对象与旧字段)
const earNumber = item.earNumber || item.earNo || item.earTag || '-'
const fromPen = (item.fromPen && (item.fromPen.name || item.fromPen.code))
|| item.fromPenName || item.fromBarnName || item.fromPen || item.fromBarn || '-'
const toPen = (item.toPen && (item.toPen.name || item.toPen.code))
|| item.toPenName || item.toBarnName || item.toPen || item.toBarn || '-'
const transferTime = item.transferDate || item.transferTime || item.transferAt || item.createdAt || item.createTime || item.time || item.created_at || ''
// 友好中文标签映射(补充新结构字段)
const labelMap = {
id: '记录ID',
recordId: '记录编号',
animalId: '动物ID',
cattleId: '牛只ID',
earNumber: '耳号', earNo: '耳号', earTag: '耳号',
fromPenName: '原栏舍', fromBarnName: '原栏舍', fromPen: '原栏舍', fromBarn: '原栏舍', fromPenId: '原栏舍ID',
toPenName: '目标栏舍', toBarnName: '目标栏舍', toPen: '目标栏舍', toBarn: '目标栏舍', toPenId: '目标栏舍ID',
transferDate: '转栏时间', transferTime: '转栏时间', transferAt: '转栏时间', time: '转栏时间',
createdAt: '创建时间', createTime: '创建时间', updatedAt: '更新时间', updateTime: '更新时间',
created_at: '创建时间', updated_at: '更新时间',
operator: '操作人', operatorId: '操作人ID', handler: '经办人',
remark: '备注', note: '备注', reason: '原因', status: '状态',
batchId: '批次ID', batchName: '批次',
farmId: '农场ID', farmName: '农场',
deviceNumber: '设备编号', deviceSn: '设备编号', deviceId: '设备ID',
weightBefore: '转前体重(kg)', weightAfter: '转后体重(kg)', weight: '体重(kg)'
}
// 将所有字段整理为可展示的键值对,时间戳/ISO 自动格式化
const displayFields = []
Object.keys(item).forEach((key) => {
let value = item[key]
// 嵌套对象特殊处理
if (key === 'farm' && value && typeof value === 'object') {
displayFields.push({ key: 'farm.name', label: '农场', value: normalize(value.name) })
if (value.id !== undefined) displayFields.push({ key: 'farm.id', label: '农场ID', value: normalize(value.id) })
return
}
if (key === 'fromPen' && value && typeof value === 'object') {
displayFields.push({ key: 'fromPen.name', label: '原栏舍', value: normalize(value.name) })
if (value.code) displayFields.push({ key: 'fromPen.code', label: '原栏舍编码', value: normalize(value.code) })
if (value.id !== undefined) displayFields.push({ key: 'fromPen.id', label: '原栏舍ID', value: normalize(value.id) })
return
}
if (key === 'toPen' && value && typeof value === 'object') {
displayFields.push({ key: 'toPen.name', label: '目标栏舍', value: normalize(value.name) })
if (value.code) displayFields.push({ key: 'toPen.code', label: '目标栏舍编码', value: normalize(value.code) })
if (value.id !== undefined) displayFields.push({ key: 'toPen.id', label: '目标栏舍ID', value: normalize(value.id) })
return
}
// 时间字段格式化(兼容 ISO 字符串)
if (/time|At|Date$|_at$/i.test(key)) {
value = this.formatAnyTime(value) || '-'
}
// 将 ID 字段展示为对应的名称
if (key === 'farmId') {
displayFields.push({ key: 'farmId', label: '农场', value: normalize((item.farm && item.farm.name) || value) })
return
}
if (key === 'fromPenId') {
displayFields.push({ key: 'fromPenId', label: '原栏舍', value: normalize((item.fromPen && item.fromPen.name) || value) })
return
}
if (key === 'toPenId') {
displayFields.push({ key: 'toPenId', label: '目标栏舍', value: normalize((item.toPen && item.toPen.name) || value) })
return
}
displayFields.push({
key,
label: labelMap[key] || key,
value: normalize(value)
})
})
// 去重:头部已展示的字段不在详情中重复
const headerKeys = [
'earNumber', 'earNo', 'earTag',
'fromPenName', 'fromBarnName', 'fromPen', 'fromBarn', 'fromPen.name',
'toPenName', 'toBarnName', 'toPen', 'toBarn', 'toPen.name',
'transferDate', 'transferTime', 'transferAt', 'time', 'createdAt', 'created_at', 'farm.name'
]
const details = displayFields.filter(f => !headerKeys.includes(f.key))
return {
id: item.id || item._id || '-',
recordId: item.recordId || '-',
status: item.status || '-',
earNumber,
fromPen,
toPen,
transferTimeStr: this.formatAnyTime(transferTime) || '-',
operator: item.operator || item.handler || '-',
remark: item.remark || item.note || '-',
batchName: item.batchName || '-',
farmName: (item.farm && item.farm.name) || item.farmName || '-',
deviceNumber: item.deviceNumber || item.deviceSn || item.deviceId || '-',
details,
raw: item
}
},
// 搜索输入
onSearchInput(e) {
this.setData({ searchEarNumber: e.detail.value || '' })
},
// 执行搜索(耳号精确查询)
onSearch() {
this.setData({ page: 1 })
this.loadRecords()
},
// 清空搜索
onClearSearch() {
this.setData({ searchEarNumber: '', page: 1 })
this.loadRecords()
},
// 分页构造与交互
buildPagination(total, totalPagesOverride = 0, isUnknownTotal = false) {
const pageSize = this.data.pageSize
const current = this.data.page
if (isUnknownTotal) {
const pages = Array.from({ length: Math.max(1, current) }, (_, i) => i + 1)
this.setData({ pages, lastPage: current })
return
}
const totalPages = Math.max(1, Number(totalPagesOverride) || Math.ceil(total / pageSize))
let pages = []
const maxVisible = 9
if (totalPages <= maxVisible) {
pages = Array.from({ length: totalPages }, (_, i) => i + 1)
} else {
let start = Math.max(1, current - 4)
let end = Math.min(totalPages, start + maxVisible - 1)
start = Math.max(1, end - maxVisible + 1)
pages = Array.from({ length: end - start + 1 }, (_, i) => start + i)
}
this.setData({ pages, lastPage: totalPages })
},
onPrevPage() {
if (this.data.page <= 1) return
this.setData({ page: this.data.page - 1, records: [] })
this.loadRecords()
},
onNextPage() {
if (!this.data.hasMore) return
this.setData({ page: this.data.page + 1, records: [] })
this.loadRecords()
},
onPageTap(e) {
const targetPage = Number(e.currentTarget.dataset.page)
if (!targetPage || targetPage === this.data.page) return
this.setData({ page: targetPage, records: [] })
this.loadRecords()
}
})

View File

@@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@@ -0,0 +1,82 @@
<!-- pages/cattle/transfer/transfer.wxml -->
<view class="transfer-container">
<!-- 搜索栏:耳号精确查询 -->
<view class="search-bar">
<view class="search-input-wrapper">
<input class="search-input" placeholder="请输入耳号,精确查询" value="{{searchEarNumber}}" bindinput="onSearchInput" confirm-type="search" bindconfirm="onSearch"/>
<text class="search-icon" bindtap="onSearch">🔍</text>
</view>
<text class="clear-btn" bindtap="onClearSearch" wx:if="{{searchEarNumber}}">清空</text>
</view>
<!-- 列表 -->
<view class="record-list">
<block wx:if="{{records.length > 0}}">
<block wx:for="{{records}}" wx:key="id">
<view class="record-item">
<!-- 左侧头像/图标 -->
<view class="record-avatar"><text class="avatar-icon">🏠</text></view>
<!-- 中间信息 -->
<view class="record-info">
<view class="record-title">
<text class="ear-number">耳号:{{item.earNumber}}</text>
</view>
<!-- 扩展详情(全部字段展示,已去重头部字段) -->
<view class="record-extra">
<block wx:for="{{item.details}}" wx:key="{{item.key}}">
<view class="extra-row">
<text class="extra-label">{{item.label}}</text>
<text class="extra-value">{{item.value}}</text>
</view>
</block>
</view>
</view>
<!-- 右侧元信息 -->
<view class="record-meta">
<text class="meta-item">状态:{{item.status || '-'}} </text>
<text class="meta-item">编号:{{item.recordId || '-'}} </text>
<text class="meta-item">农场:{{item.farmName || '-'}} </text>
</view>
</view>
</block>
<!-- 加载更多/无更多 -->
<view wx:if="{{hasMore}}" class="load-more">
<text wx:if="{{loading}}">加载中...</text>
<text wx:else>上拉或点击页码加载更多</text>
</view>
<view wx:if="{{!hasMore}}" class="no-more">
<text>没有更多数据了</text>
</view>
</block>
<!-- 空态 -->
<view wx:else class="empty-state">
<text class="empty-icon">📄</text>
<text class="empty-text">暂无转栏记录</text>
</view>
</view>
<!-- 分页导航 -->
<view wx:if="{{records.length > 0}}" class="pagination">
<view class="page-item {{page <= 1 ? 'disabled' : ''}}" bindtap="onPrevPage">上一页</view>
<view class="page-item" data-page="1" bindtap="onPageTap">首页</view>
<view
wx:for="{{pages}}"
wx:key="*this"
class="page-item {{item === page ? 'active' : ''}}"
data-page="{{item}}"
bindtap="onPageTap"
>{{item}}</view>
<view class="page-item" data-page="{{lastPage}}" bindtap="onPageTap">末页</view>
<view class="page-item {{page >= lastPage ? 'disabled' : ''}}" bindtap="onNextPage">下一页</view>
</view>
<!-- 加载状态 -->
<view wx:if="{{loading && records.length === 0}}" class="loading-container">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</view>

View File

@@ -0,0 +1,211 @@
/* pages/cattle/transfer/transfer.wxss */
.transfer-container {
background-color: #f6f6f6;
min-height: 100vh;
padding-bottom: 120rpx;
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx;
background-color: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
}
.search-input-wrapper {
flex: 1;
position: relative;
margin-right: 16rpx;
}
.search-input {
width: 100%;
height: 72rpx;
background-color: #f5f5f5;
border-radius: 36rpx;
padding: 0 60rpx 0 24rpx;
font-size: 28rpx;
color: #303133;
}
.search-icon {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
font-size: 32rpx;
color: #909399;
}
.clear-btn {
font-size: 28rpx;
color: #3cc51f;
padding: 8rpx 16rpx;
}
.record-list {
padding: 16rpx;
}
.record-item {
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.record-item:active {
transform: scale(0.98);
box-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.1);
}
.record-avatar {
width: 80rpx;
height: 80rpx;
background-color: #f0f9ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.avatar-icon {
font-size: 40rpx;
}
.record-info {
flex: 1;
margin-right: 16rpx;
}
.record-title {
font-size: 32rpx;
font-weight: 500;
color: #303133;
margin-bottom: 8rpx;
}
.ear-number {
color: #303133;
}
.record-details {
display: flex;
flex-direction: column;
margin-bottom: 8rpx;
}
.detail-item {
font-size: 24rpx;
color: #606266;
margin-bottom: 4rpx;
}
.record-extra {
margin-top: 8rpx;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6rpx 12rpx;
}
.extra-row {
display: flex;
align-items: center;
font-size: 22rpx;
}
.extra-label {
color: #909399;
margin-right: 8rpx;
}
.extra-value {
color: #303133;
}
.record-meta {
display: flex;
flex-direction: column;
}
.meta-item {
font-size: 22rpx;
color: #909399;
margin-bottom: 2rpx;
}
.load-more {
text-align: center;
padding: 32rpx;
font-size: 24rpx;
color: #909399;
}
.no-more {
text-align: center;
padding: 32rpx;
font-size: 24rpx;
color: #c0c4cc;
}
/* 分页导航 */
.pagination {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
align-items: center;
justify-content: center;
padding: 20rpx 16rpx 40rpx;
}
.page-item {
min-width: 60rpx;
padding: 12rpx 20rpx;
border-radius: 8rpx;
background-color: #f5f5f5;
color: #606266;
font-size: 26rpx;
text-align: center;
}
.page-item.active {
background-color: #3cc51f;
color: #ffffff;
}
.page-item.disabled {
opacity: 0.5;
pointer-events: none;
}
.loading-container {
text-align: center;
padding: 120rpx 32rpx;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid #f0f0f0;
border-top: 4rpx solid #3cc51f;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16rpx;
}
.loading-text {
font-size: 28rpx;
color: #909399;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -77,7 +77,7 @@ Page({
// 加载数据
loadData() {
const { currentPage, pageSize, searchValue } = this.data
const url = `https://ad.ningmuyun.com/api/smart-devices/collars?page=${currentPage}&limit=${pageSize}&deviceId=${searchValue}&_t=${Date.now()}`
const url = `https://ad.ningmuyun.com/farm/api/smart-devices/collars?page=${currentPage}&limit=${pageSize}&deviceId=${searchValue}&_t=${Date.now()}`
// 检查登录状态
const token = wx.getStorageSync('token')
@@ -290,7 +290,7 @@ Page({
// 执行精确搜索
performExactSearch(searchValue) {
const url = `https://ad.ningmuyun.com/api/smart-devices/collars?page=1&limit=1&deviceId=${searchValue}&_t=${Date.now()}`
const url = `https://ad.ningmuyun.com/farm/api/smart-devices/collars?page=1&limit=1&deviceId=${searchValue}&_t=${Date.now()}`
// 检查登录状态
const token = wx.getStorageSync('token')

View File

@@ -66,7 +66,7 @@ Page({
// 调用私有API获取耳标列表然后筛选出指定耳标
const response = await new Promise((resolve, reject) => {
wx.request({
url: 'https://ad.ningmuyun.com/api/smart-devices/eartags',
url: 'https://ad.ningmuyun.com/farm/api/smart-devices/eartags',
method: 'GET',
data: {
page: 1,

View File

@@ -113,7 +113,7 @@ Page({
const response = await new Promise((resolve, reject) => {
wx.request({
url: 'https://ad.ningmuyun.com/api/smart-devices/eartags',
url: 'https://ad.ningmuyun.com/farm/api/smart-devices/eartags',
method: 'GET',
data: {
page: page,
@@ -500,7 +500,7 @@ Page({
// 调用API获取所有数据的总数不受分页限制
const response = await new Promise((resolve, reject) => {
wx.request({
url: 'https://ad.ningmuyun.com/api/smart-devices/eartags',
url: 'https://ad.ningmuyun.com/farm/api/smart-devices/eartags',
method: 'GET',
data: {
page: 1,
@@ -621,7 +621,7 @@ Page({
// 调用私有API获取所有数据然后进行客户端搜索
const response = await new Promise((resolve, reject) => {
wx.request({
url: 'https://ad.ningmuyun.com/api/smart-devices/eartags',
url: 'https://ad.ningmuyun.com/farm/api/smart-devices/eartags',
method: 'GET',
data: {
page: 1,

View File

@@ -100,7 +100,7 @@ Page({
if (!this.checkLoginStatus()) return
const token = wx.getStorageSync('token')
const url = `https://ad.ningmuyun.com/api/electronic-fence?page=1&limit=100&_t=${Date.now()}`
const url = `https://ad.ningmuyun.com/farm/api/electronic-fence?page=1&limit=100&_t=${Date.now()}`
this.setData({ loading: true })
wx.request({

View File

@@ -61,7 +61,7 @@ Page({
// 加载数据
loadData() {
const { currentPage, pageSize } = this.data
const url = `https://ad.ningmuyun.com/api/smart-devices/hosts?page=${currentPage}&limit=${pageSize}&_t=${Date.now()}&refresh=true`
const url = `https://ad.ningmuyun.com/farm/api/smart-devices/hosts?page=${currentPage}&limit=${pageSize}&_t=${Date.now()}&refresh=true`
const token = wx.getStorageSync('token')
if (!token) {
@@ -303,7 +303,7 @@ Page({
// 执行精确搜索
performExactSearch(searchValue) {
const url = `https://ad.ningmuyun.com/api/smart-devices/hosts?page=1&limit=1&deviceId=${searchValue}&_t=${Date.now()}`
const url = `https://ad.ningmuyun.com/farm/api/smart-devices/hosts?page=1&limit=1&deviceId=${searchValue}&_t=${Date.now()}`
const token = wx.getStorageSync('token')
if (!token) {

View File

@@ -1,5 +1,6 @@
// pages/home/home.js
const { get } = require('../../utils/api')
const { alertApi } = require('../../services/api.js')
const { formatTime } = require('../../utils/index')
Page({
@@ -123,7 +124,7 @@ Page({
},
// 更新预警数据
updateAlertData(tabIndex) {
async updateAlertData(tabIndex) {
let alertData = []
switch (tabIndex) {
@@ -139,15 +140,9 @@ Page({
]
break
case 1: // 耳标预警
alertData = [
{ title: '今日未被采集', value: '2', isAlert: false, bgIcon: '📄', url: '/pages/alert/eartag' },
{ title: '耳标脱落', value: '1', isAlert: true, bgIcon: '👂', url: '/pages/alert/eartag' },
{ title: '温度异常', value: '0', isAlert: false, bgIcon: '🌡️', url: '/pages/alert/eartag' },
{ title: '心率异常', value: '1', isAlert: true, bgIcon: '💓', url: '/pages/alert/eartag' },
{ title: '位置异常', value: '0', isAlert: false, bgIcon: '📍', url: '/pages/alert/eartag' },
{ title: '电量偏低', value: '3', isAlert: false, bgIcon: '🔋', url: '/pages/alert/eartag' }
]
break
// 动态调用真实耳标预警接口,使用返回数据填充首页卡片
await this.fetchEartagAlertCards()
return
case 2: // 脚环预警
alertData = [
{ title: '今日未被采集', value: '1', isAlert: false, bgIcon: '📄', url: '/pages/alert/ankle' },
@@ -169,6 +164,47 @@ Page({
this.setData({ currentAlertData: alertData })
},
// 动态加载耳标预警数据并映射到首页卡片
async fetchEartagAlertCards() {
try {
this.setData({ loading: true })
const params = { search: '', alertType: '', page: 1, limit: 10 }
const res = await alertApi.getEartagAlerts(params)
// 兼容响应结构:可能是 { success, data: [...], stats, pagination } 或直接返回 { data: [...], stats }
const list = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
const stats = res?.stats || {}
// 统计映射根据公开API字段
const offline = Number(stats.offline || 0) // 离线数量(近似“今日未被采集”)
const highTemperature = Number(stats.highTemperature || 0) // 温度异常数量
const lowBattery = Number(stats.lowBattery || 0) // 电量偏低数量
const abnormalMovement = Number(stats.abnormalMovement || 0) // 运动异常/当日运动量为0
// 构建首页卡片保持既有文案无法统计的置0
const alertData = [
{ title: '今日未被采集', value: String(offline), isAlert: offline > 0, bgIcon: '📄', url: '/pages/alert/alert?type=eartag' },
{ title: '耳标脱落', value: '0', isAlert: false, bgIcon: '👂', url: '/pages/alert/alert?type=eartag' },
{ title: '温度异常', value: String(highTemperature), isAlert: highTemperature > 0, bgIcon: '🌡️', url: '/pages/alert/alert?type=eartag' },
{ title: '心率异常', value: '0', isAlert: false, bgIcon: '💓', url: '/pages/alert/alert?type=eartag' },
{ title: '位置异常', value: '0', isAlert: false, bgIcon: '📍', url: '/pages/alert/alert?type=eartag' },
{ title: '电量偏低', value: String(lowBattery), isAlert: lowBattery > 0, bgIcon: '🔋', url: '/pages/alert/alert?type=eartag' }
]
// 如果有“运动异常”,在卡片上以“今日运动量异常”补充显示(替代心率异常)
if (abnormalMovement > 0) {
alertData.splice(3, 1, { title: '今日运动量异常', value: String(abnormalMovement), isAlert: true, bgIcon: '📉', url: '/pages/alert/alert?type=eartag' })
}
this.setData({ currentAlertData: alertData })
} catch (error) {
console.error('获取耳标预警失败:', error)
wx.showToast({ title: '耳标预警加载失败', icon: 'none' })
// 失败时保持原有数据不变
} finally {
this.setData({ loading: false })
}
},
// 导航到指定页面
navigateTo(e) {
const url = e.currentTarget.dataset.url

View File

@@ -126,7 +126,7 @@ Page({
try {
const response = await new Promise((resolve, reject) => {
wx.request({
url: 'https://ad.ningmuyun.com/api/auth/login',
url: 'https://ad.ningmuyun.com/farm/api/auth/login',
method: 'POST',
data: {
username: username,

View File

@@ -4,6 +4,30 @@ Page({
loading: false
},
// 直接跳转:牛-栏舍设置
goCattlePenSettings() {
console.log('准备跳转到牛-栏舍设置页面')
wx.navigateTo({
url: '/pages/cattle/pens/pens',
fail: (error) => {
console.error('页面跳转失败:', error)
wx.showToast({ title: '页面不存在', icon: 'none' })
}
})
},
// 直接跳转:牛-批次设置
goCattleBatchSettings() {
console.log('准备跳转到牛-批次设置页面')
wx.navigateTo({
url: '/pages/cattle/batches/batches',
fail: (error) => {
console.error('页面跳转失败:', error)
wx.showToast({ title: '页面不存在', icon: 'none' })
}
})
},
onLoad() {
console.log('生产管理页面加载')
},
@@ -19,6 +43,21 @@ Page({
}, 1000)
},
// 进入牛只管理(普通页面)
goCattleManage() {
// 牛只管理并非 app.json 的 tabBar 页面,使用 navigateTo 进行跳转
wx.navigateTo({
url: '/pages/cattle/cattle',
fail: (err) => {
console.error('跳转牛只管理失败:', err)
wx.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
},
// 导航到指定页面
navigateTo(e) {
const url = e.currentTarget.dataset.url
@@ -75,6 +114,7 @@ Page({
}
const animalNames = {
'cattle': '牛',
'pig': '猪',
'sheep': '羊',
'poultry': '家禽'
@@ -83,6 +123,55 @@ Page({
const functionName = functionNames[functionType] || '未知功能'
const animalName = animalNames[animalType] || '未知动物'
// 对“牛-转栏记录”和“牛-离栏记录”开放跳转,其它功能保留提示
if (animalType === 'cattle' && functionType === 'pen-transfer') {
wx.navigateTo({
url: '/pages/cattle/transfer/transfer',
fail: (error) => {
console.error('页面跳转失败:', error)
wx.showToast({ title: '页面不存在', icon: 'none' })
}
})
return
}
if (animalType === 'cattle' && functionType === 'pen-exit') {
wx.navigateTo({
url: '/pages/cattle/exit/exit',
fail: (error) => {
console.error('页面跳转失败:', error)
wx.showToast({ title: '页面不存在', icon: 'none' })
}
})
return
}
// 牛-栏舍设置:跳转到新创建的栏舍设置页面
if (animalType === 'cattle' && functionType === 'pen-settings') {
console.log('匹配到牛-栏舍设置分支,开始跳转')
wx.navigateTo({
url: '/pages/cattle/pens/pens',
fail: (error) => {
console.error('页面跳转失败:', error)
wx.showToast({ title: '页面不存在', icon: 'none' })
}
})
return
}
// 牛-批次设置:跳转到新创建的批次设置页面
if (animalType === 'cattle' && functionType === 'batch-settings') {
console.log('匹配到牛-批次设置分支,开始跳转')
wx.navigateTo({
url: '/pages/cattle/batches/batches',
fail: (error) => {
console.error('页面跳转失败:', error)
wx.showToast({ title: '页面不存在', icon: 'none' })
}
})
return
}
wx.showToast({
title: `${animalName}${functionName}功能开发中`,
icon: 'none',

View File

@@ -3,6 +3,71 @@
<!-- 页面标题 -->
<view class="page-title">生产管理</view>
<!-- 牛只管理模块 -->
<view class="management-section">
<view class="section-header">
<view class="section-title-bar"></view>
<text class="section-title">牛只管理</text>
</view>
<view class="function-grid">
<view class="function-item" bindtap="goCattleManage">
<view class="function-icon cattle-archive">🐄</view>
<text class="function-text">牛只档案</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="estrus-record">
<view class="function-icon estrus-record">💗</view>
<text class="function-text">发情记录</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="mating-record">
<view class="function-icon mating-record">🧪</view>
<text class="function-text">配种记录</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="pregnancy-check">
<view class="function-icon pregnancy-check">📅</view>
<text class="function-text">妊检记录</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="farrowing-record">
<view class="function-icon farrowing-record">👶</view>
<text class="function-text">分娩记录</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="weaning-record">
<view class="function-icon weaning-record">✏️</view>
<text class="function-text">断奶记录</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="pen-transfer">
<view class="function-icon pen-transfer">🏠</view>
<text class="function-text">转栏记录</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="pen-exit">
<view class="function-icon pen-exit">🏠</view>
<text class="function-text">离栏记录</text>
</view>
<view class="function-item" bindtap="goCattlePenSettings" data-animal="cattle" data-type="pen-settings">
<view class="function-icon pen-settings">🏠</view>
<text class="function-text">栏舍设置</text>
</view>
<view class="function-item" bindtap="goCattleBatchSettings" data-animal="cattle" data-type="batch-settings">
<view class="function-icon batch-settings">📄</view>
<text class="function-text">批次设置</text>
</view>
<view class="function-item" bindtap="onFunctionClick" data-animal="cattle" data-type="epidemic-warning">
<view class="function-icon epidemic-warning">🛡️</view>
<text class="function-text">防疫预警</text>
</view>
</view>
</view>
<!-- 猪档案管理模块 -->
<view class="management-section">
<view class="section-header">

View File

@@ -137,6 +137,11 @@
background-color: #1890ff;
}
/* 牛只管理图标颜色 */
.function-icon.cattle-archive {
background-color: #1890ff;
}
.function-icon.scan-entry {
background-color: #722ed1;
}

View File

@@ -5,7 +5,7 @@ const app = getApp()
// 基础配置
const config = {
// 使用真实的智能耳标API接口直接连接后端
baseUrl: 'https://ad.ningmuyun.com/api', // 智能耳标API地址
baseUrl: 'https://ad.ningmuyun.com/farm/api', // 智能耳标API地址(生产环境)
timeout: 10000,
header: {
'Content-Type': 'application/json'
@@ -89,49 +89,81 @@ const responseInterceptor = (response) => {
}
}
// 通用请求方法
// 通用请求方法(带故障转移)
const request = (options) => {
return new Promise((resolve, reject) => {
// 应用请求拦截器
const processedOptions = requestInterceptor({
url: config.baseUrl + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
...config.header,
...options.header
},
timeout: options.timeout || config.timeout
})
wx.request({
...processedOptions,
success: (response) => {
console.log('wx.request成功:', response)
try {
const result = responseInterceptor(response)
console.log('响应拦截器处理结果:', result)
resolve(result)
} catch (error) {
console.log('响应拦截器错误:', error)
reject(error)
}
},
fail: (error) => {
console.log('wx.request失败:', error)
let message = '网络连接异常'
if (error.errMsg) {
if (error.errMsg.includes('timeout')) {
message = '请求超时'
} else if (error.errMsg.includes('fail')) {
message = '网络连接失败'
const failoverBases = [
'http://localhost:5350/api',
'http://127.0.0.1:5350/api'
]
const allowFailover = options.allowFailover !== false
const doWxRequest = (baseUrlToUse, isFailoverAttempt = false) => {
// 应用请求拦截器
const processedOptions = requestInterceptor({
url: baseUrlToUse + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
...config.header,
...options.header
},
timeout: options.timeout || config.timeout
})
console.log('[API] 请求URL:', processedOptions.url, '方法:', processedOptions.method, '是否故障转移:', isFailoverAttempt)
wx.request({
...processedOptions,
success: (response) => {
console.log('[API] 响应状态码:', response.statusCode, '请求URL:', processedOptions.url)
// 当远程网关或上游故障5xx自动尝试本地备用服务5350
if (
response.statusCode >= 500 &&
allowFailover &&
!isFailoverAttempt &&
baseUrlToUse === config.baseUrl &&
failoverBases.length > 0
) {
console.warn('[API] 远程服务返回', response.statusCode, ',尝试切换到本地备用服务:', failoverBases[0])
return doWxRequest(failoverBases[0], true)
}
try {
const result = responseInterceptor(response)
console.log('响应拦截器处理结果:', result)
resolve(result)
} catch (error) {
console.log('响应拦截器错误:', error)
reject(error)
}
},
fail: (error) => {
console.log('wx.request失败:', error, '请求URL:', processedOptions.url)
let message = '网络连接异常'
if (error.errMsg) {
if (error.errMsg.includes('timeout')) {
message = '请求超时'
} else if (error.errMsg.includes('fail')) {
message = '网络连接失败'
}
}
// 如果是远程网络失败非HTTP响应也尝试本地备用
if (allowFailover && !isFailoverAttempt && baseUrlToUse === config.baseUrl && failoverBases.length > 0) {
console.warn('[API] 远程网络失败,尝试切换到本地备用服务:', failoverBases[0])
return doWxRequest(failoverBases[0], true)
}
reject(new Error(message))
}
reject(new Error(message))
}
})
})
}
// 首次请求使用当前配置的基础地址
doWxRequest(config.baseUrl, false)
})
}