This commit is contained in:
2025-09-28 18:04:24 +08:00
59 changed files with 5428 additions and 593 deletions

View File

@@ -1,4 +1,4 @@
# 生产环境配置
VITE_API_BASE_URL=/api
VITE_API_FULL_URL=https://ad.ningmuyun.com/api
VITE_API_FULL_URL=https://ad.ningmuyun.com/farm/api
VITE_USE_PROXY=false

View File

@@ -19,13 +19,18 @@ server {
access_log off;
}
# 处理Vue Router的history模式
location / {
try_files $uri $uri/ /index.html;
# 处理Vue Router的history模式 - 支持/farm/路径
location /farm/ {
try_files $uri $uri/ /farm/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# 根路径重定向到/farm/
location = / {
return 301 /farm/;
}
# API代理到后端服务
location /api/ {

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>重定向中...</title>
<script>
// 立即重定向到正确的路径
window.location.href = '/farm/login';
</script>
</head>
<body>
<p>正在重定向到登录页面...</p>
<p>如果没有自动跳转,请点击 <a href="/farm/login">这里</a></p>
</body>
</html>

View File

@@ -4,7 +4,7 @@ import routes from './routes'
// 创建路由实例
const router = createRouter({
history: createWebHistory(),
history: createWebHistory('/farm/'),
routes,
scrollBehavior(to, from, savedPosition) {
// 如果有保存的位置,则恢复到保存的位置
@@ -30,6 +30,12 @@ router.beforeEach(async (to, from, next) => {
return
}
// 处理根路径访问,重定向到仪表盘
if (to.path === '/') {
next('/dashboard')
return
}
// 如果访问登录页面且已有有效token重定向到仪表盘
if (to.path === '/login' && userStore.token && userStore.isLoggedIn) {
const redirectPath = to.query.redirect || '/dashboard'

View File

@@ -31,7 +31,7 @@
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.highTemperature }}</div>
<div class="stat-label">预警</div>
<div class="stat-label">温度预警</div>
</div>
</div>
<div class="stat-card">
@@ -392,7 +392,7 @@ const getAlertTypeText = (type) => {
'movement': '异常运动预警',
'wear': '佩戴异常预警'
}
return typeMap[type] || '未知预警'
return typeMap[type] || '温度预警'
}
// 获取预警类型颜色

View File

@@ -204,6 +204,7 @@ import { message } from 'ant-design-vue'
import { SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
import { ExportUtils } from '../utils/exportUtils'
import { loadBMapScript, createMap } from '@/utils/mapService'
import { API_CONFIG } from '@/config/env.js'
// 响应式数据
const hosts = ref([])
@@ -312,8 +313,8 @@ const fetchData = async (showMessage = false) => {
console.log('搜索条件:', searchValue.value.trim())
}
// 调用API获取智能主机数据
const apiUrl = `/api/smart-devices/hosts?${params}`
// 调用API获取智能主机数据使用环境配置的基础URL兼容生产 /farm/api 前缀)
const apiUrl = `${API_CONFIG.baseUrl}/smart-devices/hosts?${params}`
console.log('API请求URL:', apiUrl)
const response = await fetch(apiUrl, {
@@ -610,8 +611,8 @@ const exportData = async () => {
console.log('导出搜索条件:', searchValue.value.trim())
}
// 调用API获取所有智能主机数据
const apiUrl = `/api/smart-devices/hosts?${params}`
// 调用API获取所有智能主机数据使用环境配置的基础URL兼容生产 /farm/api 前缀)
const apiUrl = `${API_CONFIG.baseUrl}/smart-devices/hosts?${params}`
console.log('导出API请求URL:', apiUrl)
const response = await fetch(apiUrl, {

View File

@@ -7,8 +7,22 @@ export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd(), '')
// 自定义重定向插件
const redirectPlugin = () => {
return {
name: 'redirect-plugin',
configureServer(server) {
server.middlewares.use('/login', (req, res, next) => {
res.writeHead(302, { Location: '/farm/login' })
res.end()
})
}
}
}
return {
plugins: [vue()],
base: '/farm/',
plugins: [vue(), redirectPlugin()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
@@ -23,6 +37,11 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api')
}
},
// 开发环境重定向配置
middlewareMode: false,
fs: {
strict: false
}
},
build: {
@@ -38,7 +57,11 @@ export default defineConfig(({ mode }) => {
},
define: {
// 将环境变量注入到应用中
__APP_ENV__: JSON.stringify(env)
__APP_ENV__: JSON.stringify(env),
// 在生产环境中强制使用正确的API URL
'import.meta.env.VITE_API_BASE_URL': JSON.stringify(
mode === 'production' ? 'https://ad.ningmuyun.com/farm/api' : (env.VITE_API_BASE_URL || '/api')
)
}
}
})

View File

@@ -7,7 +7,7 @@ const express = require('express');
const router = express.Router();
const { verifyToken, checkRole } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permission');
const { IotXqClient, IotJbqServer, IotJbqClient } = require('../models');
const { IotXqClient, IotJbqServer, IotJbqClient, Farm } = require('../models');
const { Op } = require('sequelize');
const { createSuccessResponse, createErrorResponse, createPaginatedResponse, SUCCESS_MESSAGES, ERROR_CODES } = require('../utils/apiResponse');
@@ -1366,6 +1366,14 @@ router.get('/hosts', verifyToken, requirePermission('smart_host:view'), async (r
]
});
// 批量查询牧场信息,构建映射表
const orgIds = [...new Set(rows.map(h => h.org_id).filter(id => id !== null && id !== undefined))];
let farmMap = {};
if (orgIds.length > 0) {
const farms = await Farm.findAll({ where: { id: orgIds } });
farmMap = Object.fromEntries(farms.map(f => [f.id, f.name]));
}
// 格式化数据以匹配前端UI需求
const hosts = rows.map(host => ({
id: host.id,
@@ -1383,6 +1391,9 @@ router.get('/hosts', verifyToken, requirePermission('smart_host:view'), async (r
state: host.state, // 设备状态
title: host.title, // 设备标题
org_id: host.org_id, // 组织ID
farmId: host.org_id, // 牧场ID与组织ID一致
farmName: farmMap[host.org_id] || '-', // 牧场名称
farm: { id: host.org_id, name: farmMap[host.org_id] || '-' },
uid: host.uid, // 用户ID
fence_id: host.fence_id, // 围栏ID
source_id: host.source_id, // 数据源ID
@@ -1449,6 +1460,19 @@ router.get('/hosts/:id', verifyToken, requirePermission('smart_host:view'), asyn
}
// 格式化数据
// 牧场信息
let farmId = host.org_id;
let farmName = '-';
if (farmId !== null && farmId !== undefined) {
try {
const farm = await Farm.findByPk(farmId);
if (farm && farm.name) {
farmName = farm.name;
}
} catch (e) {
// 牧场查询失败时忽略,不影响主机详情返回
}
}
const hostData = {
id: host.id,
deviceNumber: host.sid,
@@ -1465,6 +1489,9 @@ router.get('/hosts/:id', verifyToken, requirePermission('smart_host:view'), asyn
state: host.state,
title: host.title,
org_id: host.org_id,
farmId: farmId,
farmName: farmName,
farm: { id: farmId, name: farmName },
uid: host.uid,
fence_id: host.fence_id,
source_id: host.source_id,

View File

@@ -116,8 +116,28 @@ const handleResponse = async (response) => {
const fetchRequest = async (url, options = {}) => {
const userStore = useUserStore()
// 处理查询参数
let finalUrl = url.startsWith('http') ? url : `${API_CONFIG.baseURL}${url}`
if (options.params) {
const searchParams = new URLSearchParams()
Object.keys(options.params).forEach(key => {
if (options.params[key] !== undefined && options.params[key] !== null) {
searchParams.append(key, options.params[key])
}
})
const queryString = searchParams.toString()
if (queryString) {
finalUrl += (finalUrl.includes('?') ? '&' : '?') + queryString
}
// 从options中移除params避免传递给fetch
delete options.params
}
// 构建完整URL
const fullUrl = url.startsWith('http') ? url : `${API_CONFIG.baseURL}${url}`
const fullUrl = finalUrl
// 对于登录、刷新token接口跳过token检查
const skipTokenCheck = url.includes('/auth/login') ||

View File

@@ -10,14 +10,14 @@
>
<a-form-item label="申请单号">
<a-input
v-model:value="searchForm.applicationNumber"
v-model:value="searchForm.application_no"
placeholder="请输入申请单号"
allow-clear
/>
</a-form-item>
<a-form-item label="投保人姓名">
<a-input
v-model:value="searchForm.applicantName"
v-model:value="searchForm.customer_name"
placeholder="请输入投保人姓名"
allow-clear
/>
@@ -170,31 +170,31 @@
size="small"
>
<a-descriptions-item label="申请单号" :span="2">
{{ selectedApplication.application_number }}
{{ selectedApplication.application_no }}
</a-descriptions-item>
<a-descriptions-item label="参保类型">
{{ selectedApplication.insurance_category === 'individual' ? '个人参保' : '企业参保' }}
</a-descriptions-item>
<a-descriptions-item label="参保险种">
{{ selectedApplication.insurance_type }}
{{ selectedApplication.insurance_type?.name || selectedApplication.insurance_type_id }}
</a-descriptions-item>
<a-descriptions-item label="保险金额">
¥{{ Number(selectedApplication.insurance_amount).toLocaleString() }}
¥{{ Number(selectedApplication.application_amount).toLocaleString() }}
</a-descriptions-item>
<a-descriptions-item label="保险期限">
{{ selectedApplication.insurance_period }}个月
</a-descriptions-item>
<a-descriptions-item label="投保人姓名">
{{ selectedApplication.applicant_name }}
{{ selectedApplication.customer_name }}
</a-descriptions-item>
<a-descriptions-item label="身份证号">
{{ maskIdCard(selectedApplication.id_card) }}
{{ maskIdCard(selectedApplication.customer_id_card) }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ maskPhone(selectedApplication.phone) }}
{{ maskPhone(selectedApplication.customer_phone) }}
</a-descriptions-item>
<a-descriptions-item label="联系地址" :span="2">
{{ selectedApplication.address }}
{{ selectedApplication.customer_address }}
</a-descriptions-item>
<a-descriptions-item label="申请时间">
{{ selectedApplication.application_date }}
@@ -277,20 +277,20 @@
<a-radio value="enterprise">企业参保</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="参保险种" name="insurance_type" :rules="[{ required: true, message: '请选择参保险种' }]">
<a-select v-model:value="createForm.insurance_type" placeholder="请选择参保险种">
<a-form-item label="参保险种" name="insurance_type_id" :rules="[{ required: true, message: '请选择参保险种' }]">
<a-select v-model:value="createForm.insurance_type_id" placeholder="请选择参保险种">
<a-select-option
v-for="type in insuranceTypes"
:key="type.id"
:value="type.name"
:value="type.id"
>
{{ type.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="保险金额" name="insurance_amount" :rules="[{ required: true, message: '请输入保险金额' }]">
<a-form-item label="保险金额" name="application_amount" :rules="[{ required: true, message: '请输入保险金额' }]">
<a-input-number
v-model:value="createForm.insurance_amount"
v-model:value="createForm.application_amount"
:min="0"
:precision="2"
style="width: 100%"
@@ -304,17 +304,17 @@
<a-select-option value="36">36个月</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="投保人姓名" name="applicant_name" :rules="[{ required: true, message: '请输入投保人姓名' }]">
<a-input v-model:value="createForm.applicant_name" placeholder="请输入投保人姓名" />
<a-form-item label="投保人姓名" name="customer_name" :rules="[{ required: true, message: '请输入投保人姓名' }]">
<a-input v-model:value="createForm.customer_name" placeholder="请输入投保人姓名" />
</a-form-item>
<a-form-item label="身份证号" name="id_card" :rules="[{ required: true, message: '请输入身份证号' }]">
<a-input v-model:value="createForm.id_card" placeholder="请输入身份证号" />
<a-form-item label="身份证号" name="customer_id_card" :rules="[{ required: true, message: '请输入身份证号' }]">
<a-input v-model:value="createForm.customer_id_card" placeholder="请输入身份证号" />
</a-form-item>
<a-form-item label="联系电话" name="phone" :rules="[{ required: true, message: '请输入联系电话' }]">
<a-input v-model:value="createForm.phone" placeholder="请输入联系电话" />
<a-form-item label="联系电话" name="customer_phone" :rules="[{ required: true, message: '请输入联系电话' }]">
<a-input v-model:value="createForm.customer_phone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="联系地址" name="address" :rules="[{ required: true, message: '请输入联系地址' }]">
<a-textarea v-model:value="createForm.address" placeholder="请输入联系地址" />
<a-form-item label="联系地址" name="customer_address" :rules="[{ required: true, message: '请输入联系地址' }]">
<a-textarea v-model:value="createForm.customer_address" placeholder="请输入联系地址" />
</a-form-item>
<a-form-item label="备注" name="remarks">
<a-textarea v-model:value="createForm.remarks" placeholder="请输入备注信息" />
@@ -347,8 +347,8 @@ const selectedApplication = ref(null)
// 搜索表单
const searchForm = reactive({
applicationNumber: '',
applicantName: '',
application_no: '',
customer_name: '',
insuranceType: '',
insuranceCategory: '',
status: ''
@@ -366,13 +366,13 @@ const reviewFormData = reactive({
// 新增申请表单
const createForm = reactive({
insurance_category: '',
insurance_type: '',
insurance_amount: null,
insurance_type_id: '',
application_amount: null,
insurance_period: '',
applicant_name: '',
id_card: '',
phone: '',
address: '',
customer_name: '',
customer_id_card: '',
customer_phone: '',
customer_address: '',
remarks: ''
})
@@ -390,14 +390,14 @@ const pagination = reactive({
const columns = [
{
title: '申请单号',
dataIndex: 'application_number',
key: 'application_number',
dataIndex: 'application_no',
key: 'application_no',
width: 180,
fixed: 'left'
},
{
title: '投保人姓名',
dataIndex: 'applicant_name',
dataIndex: 'customer_name',
key: 'applicant_name',
width: 120
},
@@ -409,14 +409,17 @@ const columns = [
},
{
title: '参保险种',
dataIndex: 'insurance_type',
key: 'insurance_type',
width: 150
dataIndex: 'insurance_type_id',
key: 'insurance_type_id',
width: 150,
customRender: ({ record }) => {
return record.insurance_type?.name || record.insurance_type_id
}
},
{
title: '保险金额',
dataIndex: 'insurance_amount',
key: 'insurance_amount',
dataIndex: 'application_amount',
key: 'application_amount',
width: 120
},
{
@@ -427,8 +430,8 @@ const columns = [
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
dataIndex: 'customer_phone',
key: 'customer_phone',
width: 130
},
{
@@ -517,8 +520,8 @@ const handleSearch = () => {
const resetSearch = () => {
Object.assign(searchForm, {
applicationNumber: '',
applicantName: '',
application_no: '',
customer_name: '',
insuranceType: '',
insuranceCategory: '',
status: ''
@@ -575,13 +578,13 @@ const showCreateModal = () => {
isEdit.value = false
Object.assign(createForm, {
insurance_category: '',
insurance_type: '',
insurance_amount: null,
insurance_type_id: '',
application_amount: null,
insurance_period: '',
applicant_name: '',
id_card: '',
phone: '',
address: '',
customer_name: '',
customer_id_card: '',
customer_phone: '',
customer_address: '',
remarks: ''
})
createModalVisible.value = true

View File

@@ -17,37 +17,74 @@
<a-form layout="inline" :model="searchForm">
<a-form-item label="理赔单号">
<a-input
v-model:value="searchForm.claim_number"
v-model:value="searchForm.claim_no"
placeholder="请输入理赔单号"
@pressEnter="handleSearch"
@change="onSearchFieldChange('claim_no', $event)"
@input="onSearchFieldInput('claim_no', $event)"
allowClear
/>
</a-form-item>
<a-form-item label="保单号">
<a-input
v-model:value="searchForm.policy_number"
v-model:value="searchForm.policy_no"
placeholder="请输入保单号"
@pressEnter="handleSearch"
@change="onSearchFieldChange('policy_no', $event)"
@input="onSearchFieldInput('policy_no', $event)"
allowClear
/>
</a-form-item>
<a-form-item label="申请人">
<a-form-item label="报案人">
<a-input
v-model:value="searchForm.applicant_name"
placeholder="请输入申请人姓名"
v-model:value="searchForm.reporter_name"
placeholder="请输入报案人姓名"
@pressEnter="handleSearch"
@change="onSearchFieldChange('reporter_name', $event)"
@input="onSearchFieldInput('reporter_name', $event)"
allowClear
/>
</a-form-item>
<a-form-item label="联系电话">
<a-input
v-model:value="searchForm.contact_phone"
placeholder="请输入联系电话"
@pressEnter="handleSearch"
@change="onSearchFieldChange('contact_phone', $event)"
@input="onSearchFieldInput('contact_phone', $event)"
allowClear
/>
</a-form-item>
<a-form-item label="事故类型">
<a-select
v-model:value="searchForm.claim_type"
placeholder="请选择事故类型"
style="width: 120px"
@change="onSearchFieldChange('claim_type', $event)"
allowClear
>
<a-select-option value="">全部</a-select-option>
<a-select-option value="disease">疾病</a-select-option>
<a-select-option value="accident">意外事故</a-select-option>
<a-select-option value="natural_disaster">自然灾害</a-select-option>
<a-select-option value="theft">盗窃</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
v-model:value="searchForm.claim_status"
placeholder="请选择状态"
style="width: 120px"
@change="onSearchFieldChange('claim_status', $event)"
allowClear
>
<a-select-option value="">全部</a-select-option>
<a-select-option value="pending">审核</a-select-option>
<a-select-option value="pending">处理</a-select-option>
<a-select-option value="investigating">调查中</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
<a-select-option value="processing">处理中</a-select-option>
<a-select-option value="completed">已完成</a-select-option>
<a-select-option value="paid">已赔付</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
@@ -73,41 +110,62 @@
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
<template v-if="column.key === 'claim_status'">
<a-tag :color="getStatusColor(record.claim_status)">
{{ getStatusText(record.claim_status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'claim_type'">
<a-tag :color="getClaimTypeColor(record.claim_type)">
{{ getClaimTypeText(record.claim_type) }}
</a-tag>
</template>
<template v-else-if="column.key === 'claim_amount'">
<span>¥{{ record.claim_amount?.toLocaleString() }}</span>
</template>
<template v-else-if="column.key === 'approved_amount'">
<span v-if="record.approved_amount">¥{{ record.approved_amount?.toLocaleString() }}</span>
<span v-else>-</span>
<template v-else-if="column.key === 'contact_phone'">
<span>{{ record.contact_phone || '-' }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button size="small" @click="handleView(record)">查看</a-button>
<a-button
size="small"
:type="record.status === 'pending' ? 'primary' : 'default'"
@click="handleProcess(record)"
:disabled="record.status !== 'pending'"
type="primary"
@click="handleEdit(record)"
>
处理
编辑
</a-button>
<a-popconfirm
title="确定要删除这条理赔记录吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record.id)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
<a-dropdown>
<a-button size="small">更多</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEdit(record)">编辑</a-menu-item>
<a-menu-item @click="handleApprove(record)" :disabled="record.status !== 'pending'">
<a-menu-item
@click="handleProcess(record)"
:disabled="record.claim_status !== 'pending'"
>
处理
</a-menu-item>
<a-menu-item
@click="handleApprove(record)"
:disabled="record.claim_status !== 'pending'"
>
通过
</a-menu-item>
<a-menu-item @click="handleReject(record)" :disabled="record.status !== 'pending'">
<a-menu-item
@click="handleReject(record)"
:disabled="record.claim_status !== 'pending'"
>
拒绝
</a-menu-item>
<a-menu-item danger @click="handleDelete(record.id)">删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -130,26 +188,29 @@
bordered
:column="2"
>
<a-descriptions-item label="理赔单号">{{ currentClaim.claim_number }}</a-descriptions-item>
<a-descriptions-item label="保单号">{{ currentClaim.policy_number }}</a-descriptions-item>
<a-descriptions-item label="申请人">{{ currentClaim.applicant_name }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentClaim.phone }}</a-descriptions-item>
<a-descriptions-item label="理赔单号">{{ currentClaim.claim_no }}</a-descriptions-item>
<a-descriptions-item label="报案人">{{ currentClaim.reporter_name }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentClaim.contact_phone }}</a-descriptions-item>
<a-descriptions-item label="保单号">{{ currentClaim.policy_no }}</a-descriptions-item>
<a-descriptions-item label="事故类型">{{ currentClaim.claim_type }}</a-descriptions-item>
<a-descriptions-item label="受影响数量">{{ currentClaim.affected_count }}</a-descriptions-item>
<a-descriptions-item label="申请金额">¥{{ currentClaim.claim_amount?.toLocaleString() }}</a-descriptions-item>
<a-descriptions-item label="审核金额" v-if="currentClaim.approved_amount">
¥{{ currentClaim.approved_amount?.toLocaleString() }}
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentClaim.apply_date }}</a-descriptions-item>
<a-descriptions-item label="事故时间">{{ currentClaim.accident_date }}</a-descriptions-item>
<a-descriptions-item label="出险地址">{{ currentClaim.incident_location }}</a-descriptions-item>
<a-descriptions-item label="报案时间">{{ currentClaim.report_date }}</a-descriptions-item>
<a-descriptions-item label="事故时间">{{ currentClaim.incident_date }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(currentClaim.status)">
{{ getStatusText(currentClaim.status) }}
<a-tag :color="getStatusColor(currentClaim.claim_status)">
{{ getStatusText(currentClaim.claim_status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="审核人">{{ currentClaim.reviewer_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="调查员">{{ currentClaim.investigator_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="调查时间">{{ currentClaim.investigation_date || '-' }}</a-descriptions-item>
<a-descriptions-item label="审核员">{{ currentClaim.reviewer_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="审核时间">{{ currentClaim.review_date || '-' }}</a-descriptions-item>
<a-descriptions-item label="拒绝原因" :span="2" v-if="currentClaim.reject_reason">
{{ currentClaim.reject_reason }}
<a-descriptions-item label="赔付金额" v-if="currentClaim.payment_amount">
¥{{ currentClaim.payment_amount?.toLocaleString() }}
</a-descriptions-item>
<a-descriptions-item label="赔付时间">{{ currentClaim.payment_date || '-' }}</a-descriptions-item>
</a-descriptions>
<a-divider />
@@ -259,26 +320,50 @@
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="理赔单号" name="claim_number">
<a-input v-model:value="formState.claim_number" placeholder="请输入理赔单号" />
<a-form-item label="理赔单号" name="claim_no">
<a-input v-model:value="formState.claim_no" placeholder="请输入理赔单号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="保单号" name="policy_number">
<a-input v-model:value="formState.policy_number" placeholder="请输入保单号" />
<a-form-item label="保单号" name="policy_no">
<a-input v-model:value="formState.policy_no" placeholder="请输入保单号" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="申请人姓名" name="applicant_name">
<a-input v-model:value="formState.applicant_name" placeholder="请输入申请人姓名" />
<a-form-item label="报案人" name="reporter_name">
<a-input v-model:value="formState.reporter_name" placeholder="请输入报案人姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="formState.phone" placeholder="请输入联系电话" />
<a-form-item label="联系电话" name="contact_phone">
<a-input v-model:value="formState.contact_phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="事故类型" name="claim_type">
<a-select v-model:value="formState.claim_type" placeholder="请选择事故类型">
<a-select-option value="disease">疾病</a-select-option>
<a-select-option value="accident">意外</a-select-option>
<a-select-option value="natural_disaster">自然灾害</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="受影响数量" name="affected_count">
<a-input-number
v-model:value="formState.affected_count"
:min="0"
:step="1"
style="width: 100%"
placeholder="请输入受影响数量"
/>
</a-form-item>
</a-col>
</a-row>
@@ -296,34 +381,30 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="申请日期" name="apply_date">
<a-date-picker
v-model:value="formState.apply_date"
style="width: 100%"
placeholder="请选择申请日期"
/>
<a-form-item label="出险地址" name="incident_location">
<a-input v-model:value="formState.incident_location" placeholder="请输入出险地址" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="事故日期" name="accident_date">
<a-form-item label="事故日期" name="incident_date">
<a-date-picker
v-model:value="formState.accident_date"
v-model:value="formState.incident_date"
style="width: 100%"
placeholder="请选择事故日期"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态" name="status">
<a-select v-model:value="formState.status" placeholder="请选择状态">
<a-select-option value="pending">审核</a-select-option>
<a-select-option value="processing">处理</a-select-option>
<a-form-item label="状态" name="claim_status">
<a-select v-model:value="formState.claim_status" placeholder="请选择状态">
<a-select-option value="pending">处理</a-select-option>
<a-select-option value="investigating">调查</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
<a-select-option value="completed">完成</a-select-option>
<a-select-option value="paid">赔付</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -366,7 +447,8 @@ import {
RedoOutlined,
FileTextOutlined
} from '@ant-design/icons-vue'
import { claimAPI } from '@/utils/api'
import { livestockClaimApi } from '@/utils/api'
import dayjs from 'dayjs'
const loading = ref(false)
const modalVisible = ref(false)
@@ -379,21 +461,25 @@ const formRef = ref()
const processFormRef = ref()
const searchForm = reactive({
claim_number: '',
policy_number: '',
applicant_name: '',
status: ''
claim_no: '',
policy_no: '',
reporter_name: '',
contact_phone: '',
claim_type: '',
claim_status: ''
})
const formState = reactive({
claim_number: '',
policy_number: '',
applicant_name: '',
phone: '',
claim_no: '',
policy_no: '',
reporter_name: '',
contact_phone: '',
claim_type: '',
affected_count: null,
claim_amount: null,
apply_date: null,
accident_date: null,
status: 'pending',
incident_location: '',
incident_date: null,
claim_status: 'pending',
accident_description: '',
process_description: '',
reject_reason: '',
@@ -419,45 +505,63 @@ const pagination = reactive({
const columns = [
{
title: '理赔单号',
dataIndex: 'claim_number',
key: 'claim_number'
dataIndex: 'claim_no',
key: 'claim_no',
width: 140
},
{
title: '报案人',
dataIndex: 'reporter_name',
key: 'reporter_name',
width: 100
},
{
title: '联系电话',
dataIndex: 'contact_phone',
key: 'contact_phone',
width: 120
},
{
title: '保单号',
dataIndex: 'policy_number',
key: 'policy_number'
dataIndex: 'policy_no',
key: 'policy_no',
width: 140
},
{
title: '申请人',
dataIndex: 'applicant_name',
key: 'applicant_name'
title: '事故类型',
dataIndex: 'claim_type',
key: 'claim_type',
width: 100
},
{
title: '申请数量',
dataIndex: 'affected_count',
key: 'affected_count',
width: 80
},
{
title: '申请金额',
key: 'claim_amount',
dataIndex: 'claim_amount'
},
{
title: '审核金额',
key: 'approved_amount',
dataIndex: 'approved_amount'
},
{
title: '申请日期',
dataIndex: 'apply_date',
key: 'apply_date',
dataIndex: 'claim_amount',
width: 120
},
{
title: '状态',
key: 'status',
dataIndex: 'status'
title: '出险地址',
dataIndex: 'incident_location',
key: 'incident_location',
width: 150
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180
title: '状态',
key: 'claim_status',
dataIndex: 'claim_status',
width: 100
},
{
title: '报案时间',
dataIndex: 'report_date',
key: 'report_date',
width: 120
},
{
title: '操作',
@@ -468,13 +572,16 @@ const columns = [
]
const rules = {
claim_number: [{ required: true, message: '请输入理赔单号' }],
policy_number: [{ required: true, message: '请输入保单号' }],
applicant_name: [{ required: true, message: '请输入申请人姓名' }],
claim_no: [{ required: true, message: '请输入理赔单号' }],
policy_no: [{ required: true, message: '请输入保单号' }],
reporter_name: [{ required: true, message: '请输入报案人姓名' }],
contact_phone: [{ required: true, message: '请输入联系电话' }],
claim_type: [{ required: true, message: '请选择事故类型' }],
affected_count: [{ required: true, message: '请输入受影响数量' }],
claim_amount: [{ required: true, message: '请输入申请金额' }],
apply_date: [{ required: true, message: '请选择申请日期' }],
accident_date: [{ required: true, message: '请选择事故日期' }],
status: [{ required: true, message: '请选择状态' }],
incident_location: [{ required: true, message: '请输入出险地址' }],
incident_date: [{ required: true, message: '请选择事故日期' }],
claim_status: [{ required: true, message: '请选择状态' }],
accident_description: [{ required: true, message: '请输入事故描述' }]
}
@@ -522,47 +629,192 @@ const getStatusText = (status) => {
return texts[status] || '未知'
}
const getClaimTypeText = (type) => {
const typeMap = {
disease: '疾病',
accident: '意外事故',
natural_disaster: '自然灾害',
theft: '盗窃',
other: '其他'
}
return typeMap[type] || type
}
const getClaimTypeColor = (type) => {
const colorMap = {
disease: 'red',
accident: 'orange',
natural_disaster: 'purple',
theft: 'volcano',
other: 'default'
}
return colorMap[type] || 'default'
}
const loadClaims = async () => {
loading.value = true
console.log('🚀 开始加载理赔数据...')
try {
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
...searchForm
pageSize: pagination.pageSize
}
console.log('理赔管理API请求参数:', params)
const response = await claimAPI.getList(params)
console.log('理赔管理API响应:', response)
console.log('📋 当前搜索表单状态:', JSON.stringify(searchForm, null, 2))
// 添加搜索条件
if (searchForm.claim_no) {
params.claim_no = searchForm.claim_no
console.log('🔍 添加理赔单号搜索条件:', searchForm.claim_no)
}
if (searchForm.policy_no) {
params.policy_no = searchForm.policy_no
console.log('🔍 添加保单号搜索条件:', searchForm.policy_no)
}
if (searchForm.reporter_name) {
params.reporter_name = searchForm.reporter_name
console.log('🔍 添加报案人搜索条件:', searchForm.reporter_name)
}
if (searchForm.contact_phone) {
params.contact_phone = searchForm.contact_phone
console.log('🔍 添加联系电话搜索条件:', searchForm.contact_phone)
}
if (searchForm.claim_type) {
params.claim_type = searchForm.claim_type
console.log('🔍 添加事故类型搜索条件:', searchForm.claim_type)
}
if (searchForm.claim_status) {
params.claim_status = searchForm.claim_status
console.log('🔍 添加状态搜索条件:', searchForm.claim_status)
}
console.log('📤 [前端->后端] API请求参数:', {
请求时间: new Date().toLocaleString(),
请求URL: '/api/livestock-claims',
请求方法: 'GET',
请求参数: JSON.stringify(params, null, 2),
分页信息: {
当前页: pagination.current,
每页条数: pagination.pageSize
}
})
const response = await livestockClaimApi.getList(params)
console.log('📥 [后端->前端] API响应:', {
响应时间: new Date().toLocaleString(),
响应状态: response.status,
响应头: response.headers,
响应数据结构: {
status: response.data?.status,
message: response.data?.message,
data类型: Array.isArray(response.data?.data) ? 'Array' : typeof response.data?.data,
data长度: response.data?.data?.length,
pagination: response.data?.pagination
},
完整响应: JSON.stringify(response.data, null, 2)
})
if (response.data && response.data.status === 'success') {
// 后端返回的数据直接是数组格式,不是{list: [], total: 8}格式
claimList.value = response.data.data || []
pagination.total = response.data.pagination?.total || 0
console.log('理赔管理数据设置成功:', claimList.value.length, '条')
const newClaimList = response.data.data || []
const newTotal = response.data.pagination?.total || 0
console.log('✅ 数据处理成功:', {
处理时间: new Date().toLocaleString(),
数据条数: newClaimList.length,
总记录数: newTotal,
数据样例: newClaimList.length > 0 ? JSON.stringify(newClaimList[0], null, 2) : '无数据'
})
claimList.value = newClaimList
pagination.total = newTotal
console.log('📊 前端状态更新:', {
更新时间: new Date().toLocaleString(),
列表长度: claimList.value.length,
分页总数: pagination.total,
当前页: pagination.current,
每页条数: pagination.pageSize
})
} else {
console.log('理赔管理响应格式错误:', response)
console.error('响应格式错误:', {
错误时间: new Date().toLocaleString(),
响应状态: response.data?.status,
错误信息: response.data?.message,
完整响应: response
})
message.error(response.data?.message || '加载理赔列表失败')
claimList.value = []
}
} catch (error) {
message.error('加载理赔列表失败')
console.error('💥 API请求失败:', {
错误时间: new Date().toLocaleString(),
错误类型: error.name,
错误信息: error.message,
错误堆栈: error.stack,
请求配置: error.config,
响应数据: error.response?.data,
响应状态: error.response?.status
})
message.error('加载理赔列表失败: ' + error.message)
} finally {
loading.value = false
console.log('🏁 理赔数据加载完成')
}
}
const handleSearch = () => {
pagination.current = 1
console.log('🔍 执行搜索操作:', {
触发时间: new Date().toLocaleString(),
搜索条件: JSON.stringify(searchForm, null, 2),
重置分页: '第1页'
})
pagination.current = 1
loadClaims()
}
const resetSearch = () => {
console.log('🔄 重置搜索表单')
searchForm.claim_no = ''
searchForm.policy_no = ''
searchForm.reporter_name = ''
searchForm.contact_phone = ''
searchForm.claim_type = ''
searchForm.claim_status = ''
console.log('🔄 重置后的搜索表单:', JSON.stringify(searchForm, null, 2))
loadClaims()
}
const resetSearch = () => {
searchForm.claim_number = ''
searchForm.policy_number = ''
searchForm.applicant_name = ''
searchForm.status = ''
handleSearch()
// 搜索字段输入监听器
const onSearchFieldInput = (fieldName, event) => {
const value = event.target ? event.target.value : event
console.log(`📝 [输入监听] ${fieldName}:`, {
输入值: value,
事件类型: 'input',
时间戳: new Date().toLocaleString(),
当前表单状态: JSON.stringify(searchForm, null, 2)
})
}
// 搜索字段变化监听器
const onSearchFieldChange = (fieldName, event) => {
const value = event.target ? event.target.value : event
console.log(`🔍 [变化监听] ${fieldName}:`, {
变化值: value,
事件类型: 'change',
时间戳: new Date().toLocaleString(),
变化前表单: JSON.stringify(searchForm, null, 2)
})
// 延迟一点时间确保 v-model 已更新
setTimeout(() => {
console.log(`🔍 [变化后] ${fieldName}:`, {
表单中的值: searchForm[fieldName],
变化后表单: JSON.stringify(searchForm, null, 2)
})
}, 10)
}
const handleTableChange = (pag) => {
@@ -574,14 +826,16 @@ const handleTableChange = (pag) => {
const showModal = () => {
editingId.value = null
Object.assign(formState, {
claim_number: '',
policy_number: '',
applicant_name: '',
phone: '',
claim_no: '',
policy_no: '',
reporter_name: '',
contact_phone: '',
claim_type: '',
affected_count: null,
claim_amount: null,
apply_date: null,
accident_date: null,
status: 'pending',
incident_location: '',
incident_date: null,
claim_status: 'pending',
accident_description: '',
process_description: '',
reject_reason: '',
@@ -590,6 +844,10 @@ const showModal = () => {
modalVisible.value = true
}
const handleAdd = () => {
showModal()
}
const handleView = (record) => {
currentClaim.value = record
detailVisible.value = true
@@ -608,26 +866,31 @@ const handleProcess = (record) => {
const handleEdit = (record) => {
editingId.value = record.id
// 复制记录数据到表单
Object.assign(formState, {
claim_number: record.claim_number,
policy_number: record.policy_number,
applicant_name: record.applicant_name,
phone: record.phone,
claim_no: record.claim_no,
policy_no: record.policy_no,
reporter_name: record.reporter_name,
contact_phone: record.contact_phone,
claim_type: record.claim_type,
affected_count: record.affected_count,
claim_amount: record.claim_amount,
apply_date: record.apply_date,
accident_date: record.accident_date,
status: record.status,
incident_location: record.incident_location,
incident_date: record.incident_date ? dayjs(record.incident_date) : null,
claim_status: record.claim_status,
accident_description: record.accident_description,
process_description: record.process_description,
reject_reason: record.reject_reason,
approved_amount: record.approved_amount
})
modalVisible.value = true
}
const handleApprove = async (record) => {
try {
// await claimAPI.approve(record.id, { approved_amount: record.claim_amount })
// await livestockClaimApi.approve(record.id, { approved_amount: record.claim_amount })
message.success('理赔申请已通过')
loadClaims()
} catch (error) {
@@ -643,7 +906,7 @@ const handleProcessOk = async () => {
try {
await processFormRef.value.validate()
// await claimAPI.process(currentClaim.value.id, processForm)
// await livestockClaimApi.process(currentClaim.value.id, processForm)
message.success('处理完成')
processVisible.value = false
loadClaims()
@@ -660,18 +923,32 @@ const handleModalOk = async () => {
try {
await formRef.value.validate()
const formData = { ...formState }
// 处理日期格式
if (formData.incident_date) {
formData.incident_date = formData.incident_date.format('YYYY-MM-DD')
}
if (editingId.value) {
// await claimAPI.update(editingId.value, formState)
message.success('理赔更新成功')
// 编辑
await livestockClaimApi.update(editingId.value, formData)
message.success('更新成功')
} else {
// await claimAPI.create(formState)
message.success('理赔创建成功')
// 新增
// 生成理赔单号
if (!formData.claim_no) {
formData.claim_no = 'LC' + Date.now()
}
await livestockClaimApi.create(formData)
message.success('新增成功')
}
modalVisible.value = false
loadClaims()
} catch (error) {
console.log('表单验证失败', error)
console.error('保存失败:', error)
message.error('保存失败: ' + (error.response?.data?.message || error.message))
}
}
@@ -681,11 +958,12 @@ const handleModalCancel = () => {
const handleDelete = async (id) => {
try {
// await claimAPI.delete(id)
message.success('理赔删除成功')
await livestockClaimApi.delete(id)
message.success('删除成功')
loadClaims()
} catch (error) {
message.error('理赔删除失败')
console.error('删除失败:', error)
message.error('删除失败: ' + (error.response?.data?.message || error.message))
}
}

View File

@@ -17,36 +17,12 @@
<a-form layout="inline" :model="searchForm">
<a-form-item label="险种名称">
<a-input
v-model:value="searchForm.name"
v-model="searchForm.name"
placeholder="请输入险种名称"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="适用范围">
<a-input
v-model:value="searchForm.applicable_scope"
placeholder="请输入适用范围"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="服务区域">
<a-input
v-model:value="searchForm.service_area"
placeholder="请输入服务区域"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="在线状态">
<a-select
v-model:value="searchForm.online_status"
placeholder="请选择在线状态"
style="width: 120px"
>
<a-select-option value="">全部</a-select-option>
<a-select-option :value="true">在线</a-select-option>
<a-select-option :value="false">离线</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">
<search-outlined />
@@ -71,25 +47,25 @@
:scroll="{ x: 1500 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'online_status'">
<template v-if="column.key === 'on_sale_status'">
<a-switch
:checked="record.online_status"
@change="(checked) => handleToggleOnlineStatus(record, checked)"
checked-children="线"
un-checked-children="离线"
:checked="record.on_sale_status"
@change="(checked) => handleToggleOnSaleStatus(record, checked)"
checked-children=""
un-checked-children="停售"
/>
</template>
<template v-else-if="column.key === 'premium_price'">
<span>{{ record.premium_price ? `¥${record.premium_price}` : '-' }}</span>
<template v-else-if="column.key === 'applicable_livestock'">
<span>{{ formatApplicableLivestock(record.applicable_livestock) }}</span>
</template>
<template v-else-if="column.key === 'experience_period'">
<span>{{ record.experience_period ? `${record.experience_period}个月` : '-' }}</span>
<template v-else-if="column.key === 'coverage_amount'">
<span>{{ formatCoverageAmount(record.coverage_amount_min, record.coverage_amount_max) }}</span>
</template>
<template v-else-if="column.key === 'insurance_period'">
<span>{{ record.insurance_period ? `${record.insurance_period}个月` : '-' }}</span>
<template v-else-if="column.key === 'insurance_term'">
<span>{{ record.insurance_term ? `${record.insurance_term}个月` : '-' }}</span>
</template>
<template v-else-if="column.key === 'product_time'">
<span>{{ record.product_time ? formatDate(record.product_time) : '-' }}</span>
<template v-else-if="column.key === 'created_at'">
<span>{{ record.created_at ? formatDate(record.created_at) : '-' }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
@@ -109,7 +85,8 @@
<!-- 新增/编辑模态框 -->
<a-modal
v-model:open="modalVisible"
:open="modalVisible"
@update:open="modalVisible = $event"
:title="isEdit ? '编辑险种' : '新增险种'"
:ok-text="isEdit ? '更新' : '创建'"
cancel-text="取消"
@@ -126,111 +103,163 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="险种名称" name="name">
<a-input v-model:value="formState.name" placeholder="请输入险种名称" />
<a-input v-model="formState.name" placeholder="请输入险种名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="适用范围" name="applicable_scope">
<a-input v-model:value="formState.applicable_scope" placeholder="请输入适用范围" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="险种描述" name="description">
<a-textarea
v-model:value="formState.description"
placeholder="请输入险种描述"
:rows="3"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="体验期" name="experience_period">
<a-input-number
v-model:value="formState.experience_period"
placeholder="请输入体验期(月)"
:min="0"
<a-form-item label="适用生资" name="applicable_livestock">
<a-select
v-model="formState.applicable_livestock"
placeholder="请选择适用生资"
style="width: 100%"
addon-after="个月"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="保险期间" name="insurance_period">
<a-input-number
v-model:value="formState.insurance_period"
placeholder="请输入保险期间(月)"
:min="0"
style="width: 100%"
addon-after="个月"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="保费价格" name="premium_price">
<a-input-number
v-model:value="formState.premium_price"
placeholder="请输入保费价格"
:min="0"
:precision="2"
style="width: 100%"
addon-before="¥"
/>
mode="multiple"
:max-tag-count="3"
>
<a-select-option value="牛"></a-select-option>
<a-select-option value=""></a-select-option>
<a-select-option value="猪"></a-select-option>
<a-select-option value="鸡"></a-select-option>
<a-select-option value="鸭"></a-select-option>
<a-select-option value="鹅"></a-select-option>
<a-select-option value=""></a-select-option>
<a-select-option value="驴"></a-select-option>
<a-select-option value="兔"></a-select-option>
<a-select-option value="鱼"></a-select-option>
<a-select-option value="虾"></a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务区域" name="service_area">
<a-input v-model:value="formState.service_area" placeholder="请输入服务区域" />
<a-col :span="8">
<a-form-item label="保险期限/月" name="insurance_term">
<a-select
v-model="formState.insurance_term"
placeholder="请选择保险期限"
style="width: 100%"
>
<a-select-option :value="1">1个月</a-select-option>
<a-select-option :value="3">3个月</a-select-option>
<a-select-option :value="6">6个月</a-select-option>
<a-select-option :value="12">12个月</a-select-option>
<a-select-option :value="24">24个月</a-select-option>
<a-select-option :value="36">36个月</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="上架时间" name="product_time">
<a-col :span="8">
<a-form-item label="服务区域" name="description">
<a-input v-model="formState.description" placeholder="请输入服务区域" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="保单形式" name="policy_form">
<a-select
v-model="formState.policy_form"
placeholder="请选择保单形式"
style="width: 100%"
>
<a-select-option value="电子保单">电子保单</a-select-option>
<a-select-option value="纸质保单">纸质保单</a-select-option>
<a-select-option value="电子保单+纸质保单">电子保单+纸质保单</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="保险额度" name="coverage_amount">
<a-input-group compact>
<a-input-number
v-model="formState.coverage_amount_min"
placeholder="最低额度"
:min="0"
:precision="2"
style="width: 45%"
addon-before="¥"
/>
<a-input
style="width: 10%; text-align: center; pointer-events: none"
placeholder="~"
disabled
/>
<a-input-number
v-model="formState.coverage_amount_max"
placeholder="最高额度"
:min="0"
:precision="2"
style="width: 45%"
addon-before="¥"
/>
</a-input-group>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="添加时间" name="created_at">
<a-date-picker
v-model:value="formState.product_time"
placeholder="请选择上架时间"
v-model="formState.created_at"
placeholder="请选择添加时间"
style="width: 100%"
format="YYYY-MM-DD HH:mm:ss"
show-time
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="在线状态" name="online_status">
<a-col :span="8">
<a-form-item label="在售状态" name="on_sale_status">
<a-switch
v-model:checked="formState.online_status"
checked-children="线"
un-checked-children="离线"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序" name="sort_order">
<a-input-number
v-model:value="formState.sort_order"
placeholder="请输入排序值"
:min="0"
style="width: 100%"
v-model="formState.on_sale_status"
checked-children=""
un-checked-children="停售"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注" name="remarks">
<a-textarea
v-model:value="formState.remarks"
placeholder="请输入备注信息"
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 详情查看模态框 -->
<a-modal
:open="detailVisible"
@update:open="detailVisible = $event"
title="险种详情"
:footer="null"
width="800px"
>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="险种名称">
{{ currentDetail.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="适用生资">
{{ formatApplicableLivestock(currentDetail.applicable_livestock) }}
</a-descriptions-item>
<a-descriptions-item label="保险期限">
{{ currentDetail.insurance_term ? `${currentDetail.insurance_term}个月` : '-' }}
</a-descriptions-item>
<a-descriptions-item label="服务区域">
{{ currentDetail.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="保单形式">
{{ currentDetail.policy_form || '-' }}
</a-descriptions-item>
<a-descriptions-item label="保险额度">
{{ formatCoverageAmount(currentDetail.coverage_amount_min, currentDetail.coverage_amount_max) }}
</a-descriptions-item>
<a-descriptions-item label="添加时间">
{{ currentDetail.created_at ? formatDate(currentDetail.created_at) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="在售状态">
<a-tag :color="currentDetail.on_sale_status ? 'green' : 'red'">
{{ currentDetail.on_sale_status ? '在售' : '停售' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="最后更新时间" :span="2">
{{ currentDetail.updated_at ? formatDate(currentDetail.updated_at) : '-' }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
@@ -247,30 +276,27 @@ import {
const loading = ref(false)
const modalVisible = ref(false)
const detailVisible = ref(false)
const isEdit = ref(false)
const typeList = ref([])
const formRef = ref()
const currentDetail = ref({})
const searchForm = reactive({
name: '',
applicable_scope: '',
service_area: '',
online_status: ''
name: ''
})
const formState = reactive({
id: null,
name: '',
applicable_livestock: [],
insurance_term: null,
description: '',
applicable_scope: '',
experience_period: null,
insurance_period: null,
premium_price: null,
service_area: '',
product_time: null,
online_status: true,
sort_order: 0,
remarks: ''
policy_form: '',
coverage_amount_min: null,
coverage_amount_max: null,
created_at: null,
on_sale_status: true
})
const pagination = reactive({
@@ -291,45 +317,52 @@ const columns = [
fixed: 'left'
},
{
title: '适用范围',
dataIndex: 'applicable_scope',
key: 'applicable_scope',
width: 150
},
{
title: '体验期',
dataIndex: 'experience_period',
key: 'experience_period',
width: 100
},
{
title: '保险期间',
dataIndex: 'insurance_period',
key: 'insurance_period',
width: 100
},
{
title: '保费价格',
dataIndex: 'premium_price',
key: 'premium_price',
title: '适用生资',
dataIndex: 'applicable_livestock',
key: 'applicable_livestock',
width: 120
},
{
title: '保险期限/月',
dataIndex: 'insurance_term',
key: 'insurance_term',
width: 120,
sorter: true
},
{
title: '服务区域',
dataIndex: 'service_area',
key: 'service_area',
dataIndex: 'description',
key: 'description',
width: 200
},
{
title: '保单形式',
dataIndex: 'policy_form',
key: 'policy_form',
width: 150,
filters: [
{ text: '电子保单', value: '电子保单' },
{ text: '纸质保单', value: '纸质保单' },
{ text: '电子保单+纸质保单', value: '电子保单+纸质保单' }
]
},
{
title: '保险额度',
dataIndex: 'coverage_amount',
key: 'coverage_amount',
width: 150
},
{
title: '上架时间',
dataIndex: 'product_time',
key: 'product_time',
width: 180
title: '添加时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
sorter: true
},
{
title: '在线状态',
dataIndex: 'online_status',
key: 'online_status',
title: '在状态',
dataIndex: 'on_sale_status',
key: 'on_sale_status',
width: 100
},
{
@@ -342,11 +375,12 @@ const columns = [
const rules = {
name: [{ required: true, message: '请输入险种名称' }],
applicable_scope: [{ required: true, message: '请输入适用范围' }],
experience_period: [{ required: true, message: '请输入体验期' }],
insurance_period: [{ required: true, message: '请输入保险期间' }],
premium_price: [{ required: true, message: '请输入保费价格' }],
service_area: [{ required: true, message: '请输入服务区域' }]
applicable_livestock: [{ required: true, message: '请输入适用生资' }],
insurance_term: [{ required: true, message: '请输入保险期限' }],
description: [{ required: true, message: '请输入服务区域' }],
policy_form: [{ required: true, message: '请选择保单形式' }],
coverage_amount_min: [{ required: true, message: '请输入最低保险额度' }],
coverage_amount_max: [{ required: true, message: '请输入最高保险额度' }]
}
// 格式化日期
@@ -355,6 +389,38 @@ const formatDate = (date) => {
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
// 格式化保险额度
const formatCoverageAmount = (min, max) => {
if (!min && !max) return '-'
if (min && max) {
return `${min}~${max}`
}
if (min) return `${min}元以上`
if (max) return `${max}元以下`
return '-'
}
// 格式化适用生资
const formatApplicableLivestock = (livestock) => {
if (!livestock) return '-'
if (Array.isArray(livestock)) {
return livestock.join('、')
}
if (typeof livestock === 'string') {
// 如果是字符串,尝试解析为数组
try {
const parsed = JSON.parse(livestock)
if (Array.isArray(parsed)) {
return parsed.join('、')
}
} catch (e) {
// 如果解析失败,按逗号分割
return livestock.split(',').join('、')
}
}
return livestock.toString()
}
const loadInsuranceTypes = async () => {
loading.value = true
try {
@@ -391,10 +457,7 @@ const handleSearch = () => {
const resetSearch = () => {
Object.assign(searchForm, {
name: '',
applicable_scope: '',
service_area: '',
online_status: ''
name: ''
})
handleSearch()
}
@@ -410,42 +473,54 @@ const showModal = () => {
Object.assign(formState, {
id: null,
name: '',
applicable_livestock: [],
insurance_term: null,
description: '',
applicable_scope: '',
experience_period: null,
insurance_period: null,
premium_price: null,
service_area: '',
product_time: null,
online_status: true,
sort_order: 0,
remarks: ''
policy_form: '',
coverage_amount_min: null,
coverage_amount_max: null,
created_at: null,
on_sale_status: true
})
modalVisible.value = true
}
const handleEdit = (record) => {
isEdit.value = true
// 处理 applicable_livestock 数据类型
let applicableLivestock = []
if (record.applicable_livestock) {
if (Array.isArray(record.applicable_livestock)) {
applicableLivestock = record.applicable_livestock
} else if (typeof record.applicable_livestock === 'string') {
try {
applicableLivestock = JSON.parse(record.applicable_livestock)
} catch (e) {
applicableLivestock = record.applicable_livestock.split(',').map(item => item.trim())
}
}
}
Object.assign(formState, {
id: record.id,
name: record.name,
applicable_livestock: applicableLivestock,
insurance_term: record.insurance_term,
description: record.description,
applicable_scope: record.applicable_scope,
experience_period: record.experience_period,
insurance_period: record.insurance_period,
premium_price: record.premium_price,
service_area: record.service_area,
product_time: record.product_time ? dayjs(record.product_time) : null,
online_status: record.online_status,
sort_order: record.sort_order,
remarks: record.remarks
policy_form: record.policy_form,
coverage_amount_min: record.coverage_amount_min,
coverage_amount_max: record.coverage_amount_max,
created_at: record.created_at ? dayjs(record.created_at) : null,
on_sale_status: record.on_sale_status
})
modalVisible.value = true
}
const handleView = (record) => {
// 查看详情功能
message.info('查看详情功能待实现')
// 设置详情数据
currentDetail.value = { ...record }
detailVisible.value = true
}
const handleSubmit = async () => {
@@ -454,7 +529,10 @@ const handleSubmit = async () => {
const submitData = {
...formState,
product_time: formState.product_time ? formState.product_time.format('YYYY-MM-DD HH:mm:ss') : null
applicable_livestock: Array.isArray(formState.applicable_livestock)
? formState.applicable_livestock.join(',')
: formState.applicable_livestock,
created_at: formState.created_at ? formState.created_at.format('YYYY-MM-DD HH:mm:ss') : null
}
if (isEdit.value) {
@@ -488,21 +566,21 @@ const handleCancel = () => {
modalVisible.value = false
}
const handleToggleOnlineStatus = async (record, checked) => {
const handleToggleOnSaleStatus = async (record, checked) => {
try {
const response = await insuranceTypeAPI.updateStatus(record.id, {
online_status: checked
on_sale_status: checked
})
if (response.status === 'success') {
message.success('在线状态更新成功')
message.success('在状态更新成功')
loadInsuranceTypes()
} else {
message.error(response.message || '在线状态更新失败')
message.error(response.message || '在状态更新失败')
}
} catch (error) {
message.error('在线状态更新失败')
console.error('Error updating online status:', error)
message.error('在状态更新失败')
console.error('Error updating on sale status:', error)
}
}

View File

@@ -129,7 +129,7 @@
:column="2"
>
<a-descriptions-item label="保单号">{{ currentPolicy.policy_number }}</a-descriptions-item>
<a-descriptions-item label="保险类型">{{ currentPolicy.insurance_type_name }}</a-descriptions-item>
<a-descriptions-item label="保险类型">{{ currentPolicy.insurance_type?.name || currentPolicy.insurance_type_name }}</a-descriptions-item>
<a-descriptions-item label="投保人">{{ currentPolicy.policyholder_name }}</a-descriptions-item>
<a-descriptions-item label="被保险人">{{ currentPolicy.insured_name }}</a-descriptions-item>
<a-descriptions-item label="保费金额">¥{{ currentPolicy.premium_amount?.toLocaleString() }}</a-descriptions-item>
@@ -353,7 +353,10 @@ const columns = [
{
title: '保险类型',
dataIndex: 'insurance_type_name',
key: 'insurance_type_name'
key: 'insurance_type_name',
customRender: ({ record }) => {
return record.insurance_type?.name || record.insurance_type_name
}
},
{
title: '投保人',

View File

@@ -214,6 +214,84 @@ const swaggerDefinition = {
updated_at: { type: 'string', format: 'date-time', description: '更新时间' }
}
},
InsuranceType: {
type: 'object',
properties: {
id: { type: 'integer', description: '险种ID' },
name: { type: 'string', description: '险种名称' },
description: { type: 'string', description: '险种描述' },
applicable_livestock: { type: 'string', description: '适用牲畜类型' },
insurance_term: { type: 'integer', description: '保险期限(月)' },
policy_form: { type: 'string', description: '保单形式' },
coverage_amount_min: { type: 'number', format: 'decimal', description: '最低保额' },
coverage_amount_max: { type: 'number', format: 'decimal', description: '最高保额' },
premium_rate: { type: 'number', format: 'decimal', description: '保险费率' },
service_area: { type: 'string', description: '服务区域' },
add_time: { type: 'string', format: 'date-time', description: '添加时间' },
on_sale_status: { type: 'boolean', description: '在售状态' },
sort_order: { type: 'integer', description: '排序顺序' },
remarks: { type: 'string', description: '备注' },
status: { type: 'string', enum: ['active', 'inactive'], description: '险种状态' },
created_by: { type: 'integer', description: '创建人ID' },
updated_by: { type: 'integer', description: '更新人ID' },
created_at: { type: 'string', format: 'date-time', description: '创建时间' },
updated_at: { type: 'string', format: 'date-time', description: '更新时间' }
}
},
CreateInsuranceTypeRequest: {
type: 'object',
required: ['name', 'coverage_amount_min', 'coverage_amount_max', 'premium_rate'],
properties: {
name: { type: 'string', description: '险种名称', minLength: 1, maxLength: 100 },
description: { type: 'string', description: '险种描述' },
applicable_livestock: { type: 'string', description: '适用牲畜类型', maxLength: 100 },
insurance_term: { type: 'integer', description: '保险期限(月)', minimum: 1 },
policy_form: { type: 'string', description: '保单形式', maxLength: 50 },
coverage_amount_min: { type: 'number', format: 'decimal', description: '最低保额', minimum: 0 },
coverage_amount_max: { type: 'number', format: 'decimal', description: '最高保额', minimum: 0 },
premium_rate: { type: 'number', format: 'decimal', description: '保险费率', minimum: 0, maximum: 1 },
service_area: { type: 'string', description: '服务区域' },
add_time: { type: 'string', format: 'date-time', description: '添加时间' },
on_sale_status: { type: 'boolean', description: '在售状态', default: true },
sort_order: { type: 'integer', description: '排序顺序', default: 0 },
remarks: { type: 'string', description: '备注' },
status: { type: 'string', enum: ['active', 'inactive'], description: '险种状态', default: 'active' }
}
},
UpdateInsuranceTypeRequest: {
type: 'object',
properties: {
name: { type: 'string', description: '险种名称', minLength: 1, maxLength: 100 },
description: { type: 'string', description: '险种描述' },
applicable_livestock: { type: 'string', description: '适用牲畜类型', maxLength: 100 },
insurance_term: { type: 'integer', description: '保险期限(月)', minimum: 1 },
policy_form: { type: 'string', description: '保单形式', maxLength: 50 },
coverage_amount_min: { type: 'number', format: 'decimal', description: '最低保额', minimum: 0 },
coverage_amount_max: { type: 'number', format: 'decimal', description: '最高保额', minimum: 0 },
premium_rate: { type: 'number', format: 'decimal', description: '保险费率', minimum: 0, maximum: 1 },
service_area: { type: 'string', description: '服务区域' },
add_time: { type: 'string', format: 'date-time', description: '添加时间' },
on_sale_status: { type: 'boolean', description: '在售状态' },
sort_order: { type: 'integer', description: '排序顺序' },
remarks: { type: 'string', description: '备注' },
status: { type: 'string', enum: ['active', 'inactive'], description: '险种状态' }
}
},
UpdateInsuranceTypeStatusRequest: {
type: 'object',
properties: {
status: { type: 'string', enum: ['active', 'inactive'], description: '险种状态' },
on_sale_status: { type: 'boolean', description: '在售状态' }
}
},
Pagination: {
type: 'object',
properties: {
page: { type: 'integer', description: '当前页码' },
limit: { type: 'integer', description: '每页数量' },
total: { type: 'integer', description: '总记录数' }
}
},
Error: {
type: 'object',
properties: {
@@ -293,6 +371,7 @@ const swaggerDefinition = {
tags: [
{ name: '认证', description: '用户认证相关接口' },
{ name: '用户管理', description: '用户管理相关接口' },
{ name: '险种管理', description: '保险险种管理相关接口' },
{ name: '保险申请', description: '保险申请管理相关接口' },
{ name: '保单管理', description: '保单管理相关接口' },
{ name: '理赔管理', description: '理赔管理相关接口' },

View File

@@ -5,7 +5,15 @@ const responseFormat = require('../utils/response');
// 获取保险类型列表
const getInsuranceTypes = async (req, res) => {
try {
const { page = 1, pageSize = 10, name, status } = req.query;
const {
page = 1,
pageSize = 10,
name,
status,
applicable_livestock,
service_area,
on_sale_status
} = req.query;
const offset = (page - 1) * pageSize;
const whereClause = {};
@@ -15,12 +23,21 @@ const getInsuranceTypes = async (req, res) => {
if (status) {
whereClause.status = status;
}
if (applicable_livestock) {
whereClause.applicable_livestock = { [Op.like]: `%${applicable_livestock}%` };
}
if (service_area) {
whereClause.service_area = { [Op.like]: `%${service_area}%` };
}
if (on_sale_status !== undefined && on_sale_status !== '') {
whereClause.on_sale_status = on_sale_status === 'true';
}
const { count, rows } = await InsuranceType.findAndCountAll({
where: whereClause,
limit: parseInt(pageSize),
offset: offset,
order: [['created_at', 'DESC']]
order: [['add_time', 'DESC'], ['created_at', 'DESC']]
});
res.json(responseFormat.pagination(rows, {
@@ -57,9 +74,17 @@ const createInsuranceType = async (req, res) => {
const {
name,
description,
applicable_livestock,
insurance_term,
policy_form,
coverage_amount_min,
coverage_amount_max,
premium_rate,
service_area,
add_time,
on_sale_status = true,
sort_order = 0,
remarks,
status = 'active'
} = req.body;
@@ -72,9 +97,17 @@ const createInsuranceType = async (req, res) => {
const insuranceType = await InsuranceType.create({
name,
description,
applicable_livestock,
insurance_term,
policy_form,
coverage_amount_min,
coverage_amount_max,
premium_rate,
service_area,
add_time: add_time || new Date(),
on_sale_status,
sort_order,
remarks,
status,
created_by: req.user?.id
});
@@ -97,9 +130,17 @@ const updateInsuranceType = async (req, res) => {
const {
name,
description,
applicable_livestock,
insurance_term,
policy_form,
coverage_amount_min,
coverage_amount_max,
premium_rate,
service_area,
add_time,
on_sale_status,
sort_order,
remarks,
status
} = req.body;
@@ -123,10 +164,18 @@ const updateInsuranceType = async (req, res) => {
await insuranceType.update({
name: name || insuranceType.name,
description: description || insuranceType.description,
coverage_amount_min: coverage_amount_min || insuranceType.coverage_amount_min,
coverage_amount_max: coverage_amount_max || insuranceType.coverage_amount_max,
premium_rate: premium_rate || insuranceType.premium_rate,
description: description !== undefined ? description : insuranceType.description,
applicable_livestock: applicable_livestock !== undefined ? applicable_livestock : insuranceType.applicable_livestock,
insurance_term: insurance_term !== undefined ? insurance_term : insuranceType.insurance_term,
policy_form: policy_form !== undefined ? policy_form : insuranceType.policy_form,
coverage_amount_min: coverage_amount_min !== undefined ? coverage_amount_min : insuranceType.coverage_amount_min,
coverage_amount_max: coverage_amount_max !== undefined ? coverage_amount_max : insuranceType.coverage_amount_max,
premium_rate: premium_rate !== undefined ? premium_rate : insuranceType.premium_rate,
service_area: service_area !== undefined ? service_area : insuranceType.service_area,
add_time: add_time !== undefined ? add_time : insuranceType.add_time,
on_sale_status: on_sale_status !== undefined ? on_sale_status : insuranceType.on_sale_status,
sort_order: sort_order !== undefined ? sort_order : insuranceType.sort_order,
remarks: remarks !== undefined ? remarks : insuranceType.remarks,
status: status || insuranceType.status,
updated_by: req.user?.id
});
@@ -152,16 +201,22 @@ const deleteInsuranceType = async (req, res) => {
return res.status(404).json(responseFormat.error('险种不存在'));
}
// 检查是否有相关的保险申请或保单
// const hasApplications = await insuranceType.countInsuranceApplications();
// if (hasApplications > 0) {
// return res.status(400).json(responseFormat.error('该险种下存在保险申请,无法删除'));
// }
// 检查是否有相关的保险申请
const hasApplications = await insuranceType.countApplications();
if (hasApplications > 0) {
return res.status(400).json(responseFormat.error('该险种下存在保险申请,无法删除', 400));
}
await insuranceType.destroy();
res.json(responseFormat.success(null, '删除险种成功'));
} catch (error) {
console.error('删除险种错误:', error);
// 处理外键约束错误
if (error.name === 'SequelizeForeignKeyConstraintError') {
return res.status(400).json(responseFormat.error('该险种下存在相关数据,无法删除', 400));
}
res.status(500).json(responseFormat.error('删除险种失败'));
}
};
@@ -170,14 +225,18 @@ const deleteInsuranceType = async (req, res) => {
const updateInsuranceTypeStatus = async (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;
const { status, on_sale_status } = req.body;
const insuranceType = await InsuranceType.findByPk(id);
if (!insuranceType) {
return res.status(404).json(responseFormat.error('险种不存在'));
}
await insuranceType.update({ status, updated_by: req.user?.id });
const updateData = { updated_by: req.user?.id };
if (status !== undefined) updateData.status = status;
if (on_sale_status !== undefined) updateData.on_sale_status = on_sale_status;
await insuranceType.update(updateData);
res.json(responseFormat.success(insuranceType, '更新险种状态成功'));
} catch (error) {
console.error('更新险种状态错误:', error);

View File

@@ -8,10 +8,35 @@ const { Op } = require('sequelize');
// 获取生资理赔列表
const getLivestockClaims = async (req, res) => {
try {
console.log('🚀 [后端] 开始处理理赔列表查询请求');
console.log('📥 [后端] 接收到的查询参数:', {
接收时间: new Date().toLocaleString(),
完整URL: req.originalUrl,
查询字符串: req.url,
原始查询参数: JSON.stringify(req.query, null, 2),
查询参数类型: typeof req.query,
查询参数键: Object.keys(req.query),
请求方法: req.method,
请求路径: req.path,
用户信息: req.user ? { id: req.user.id, username: req.user.username } : '未登录'
});
console.log('🔍 [详细调试] 请求信息:');
console.log('req.url:', req.url);
console.log('req.originalUrl:', req.originalUrl);
console.log('req.baseUrl:', req.baseUrl);
console.log('req.path:', req.path);
console.log('查询字符串:', req.url.split('?')[1] || '无');
console.log('原始查询参数:', req.query);
console.log('查询参数类型:', typeof req.query);
console.log('查询参数键:', Object.keys(req.query));
console.log('查询参数JSON:', JSON.stringify(req.query));
const {
claim_no,
policy_no,
farmer_name,
reporter_name,
contact_phone,
claim_status,
claim_type,
start_date,
@@ -20,21 +45,49 @@ const getLivestockClaims = async (req, res) => {
limit = 10
} = req.query;
console.log('🔍 [后端] 解析后的查询参数:', {
理赔单号: claim_no,
保单号: policy_no,
报案人: reporter_name,
联系电话: contact_phone,
理赔状态: claim_status,
理赔类型: claim_type,
开始日期: start_date,
结束日期: end_date,
页码: page,
每页条数: limit
});
const whereClause = {};
// 理赔编号筛选
// 理赔编号筛选 - 精确匹配
if (claim_no) {
whereClause.claim_no = { [Op.like]: `%${claim_no}%` };
whereClause.claim_no = claim_no;
console.log('🔍 [后端] 添加理赔编号筛选条件(精确匹配):', whereClause.claim_no);
}
// 理赔状态筛选
if (claim_status) {
whereClause.claim_status = claim_status;
console.log('🔍 [后端] 添加理赔状态筛选条件:', whereClause.claim_status);
}
// 理赔类型筛选
if (claim_type) {
whereClause.claim_type = claim_type;
console.log('🔍 [后端] 添加理赔类型筛选条件:', whereClause.claim_type);
}
// 报案人筛选
if (reporter_name) {
whereClause.reporter_name = { [Op.like]: `%${reporter_name}%` };
console.log('🔍 [后端] 添加报案人筛选条件:', whereClause.reporter_name);
}
// 联系电话筛选
if (contact_phone) {
whereClause.contact_phone = { [Op.like]: `%${contact_phone}%` };
console.log('🔍 [后端] 添加联系电话筛选条件:', whereClause.contact_phone);
}
// 日期范围筛选
@@ -42,17 +95,30 @@ const getLivestockClaims = async (req, res) => {
whereClause.incident_date = {
[Op.between]: [new Date(start_date), new Date(end_date)]
};
console.log('🔍 [后端] 添加日期范围筛选条件:', whereClause.incident_date);
}
const offset = (page - 1) * limit;
const { count, rows } = await LivestockClaim.findAndCountAll({
console.log('📊 [后端] 构建的查询条件:', {
构建时间: new Date().toLocaleString(),
WHERE条件: JSON.stringify(whereClause, null, 2),
分页信息: {
页码: page,
每页条数: limit,
偏移量: offset
}
});
console.log('🗄️ [后端] 开始执行数据库查询...');
const queryConfig = {
where: whereClause,
include: [
{
model: LivestockPolicy,
as: 'policy',
attributes: ['id', 'policy_no', 'farmer_name', 'farmer_phone', 'livestock_count'],
attributes: ['id', 'policy_no', 'policyholder_name', 'policyholder_phone', 'livestock_count'],
where: policy_no ? { policy_no: { [Op.like]: `%${policy_no}%` } } : {},
required: !!policy_no,
include: [
@@ -77,23 +143,66 @@ const getLivestockClaims = async (req, res) => {
order: [['created_at', 'DESC']],
offset,
limit: parseInt(limit)
};
console.log('🗄️ [后端] 数据库查询配置:', {
查询时间: new Date().toLocaleString(),
查询配置: JSON.stringify(queryConfig, null, 2)
});
// 如果有农户姓名筛选,需要在关联查询后再过滤
let filteredRows = rows;
if (farmer_name) {
filteredRows = rows.filter(claim =>
claim.policy && claim.policy.farmer_name.includes(farmer_name)
);
}
console.log('🚀 [后端] 即将执行查询,最终配置:', JSON.stringify(queryConfig, null, 2));
res.json(responseFormat.pagination(filteredRows, {
const { count, rows } = await LivestockClaim.findAndCountAll(queryConfig);
console.log('✅ [后端] 数据库查询完成:', {
查询时间: new Date().toLocaleString(),
返回总数: count,
返回记录数: rows.length,
记录ID列表: rows.map(r => ({ id: r.id, claim_no: r.claim_no }))
});
console.log('📋 [后端] 详细查询结果:', {
查询完成时间: new Date().toLocaleString(),
查询结果统计: {
总记录数: count,
当前页记录数: rows.length,
查询耗时: '已完成'
},
数据样例: rows.length > 0 ? {
第一条记录ID: rows[0].id,
第一条记录理赔单号: rows[0].claim_no,
第一条记录状态: rows[0].claim_status
} : '无数据'
});
const responseData = responseFormat.pagination(rows, {
page: parseInt(page),
limit: parseInt(limit),
total: count
}, '获取生资理赔列表成功'));
}, '获取生资理赔列表成功');
console.log('📤 [后端->前端] 准备发送响应:', {
响应时间: new Date().toLocaleString(),
响应状态: 'success',
响应数据结构: {
status: responseData.status,
message: responseData.message,
data类型: Array.isArray(responseData.data) ? 'Array' : typeof responseData.data,
data长度: responseData.data?.length,
pagination: responseData.pagination
},
完整响应: JSON.stringify(responseData, null, 2)
});
res.json(responseData);
} catch (error) {
console.error('获取生资理赔列表错误:', error);
console.error('💥 [后端] 获取生资理赔列表错误:', {
错误时间: new Date().toLocaleString(),
错误类型: error.name,
错误信息: error.message,
错误堆栈: error.stack,
SQL错误: error.sql || '无SQL信息'
});
res.status(500).json(responseFormat.error('获取生资理赔列表失败'));
}
};
@@ -355,10 +464,88 @@ const getLivestockClaimStats = async (req, res) => {
}
};
// 更新生资理赔
const updateLivestockClaim = async (req, res) => {
try {
const { id } = req.params;
const updateData = req.body;
const claim = await LivestockClaim.findByPk(id);
if (!claim) {
return res.status(404).json(responseFormat.error('生资理赔不存在'));
}
// 更新理赔信息
await claim.update({
...updateData,
updated_by: req.user?.id
});
// 获取更新后的完整信息
const updatedClaim = await LivestockClaim.findByPk(id, {
include: [
{
model: LivestockPolicy,
as: 'policy',
attributes: ['id', 'policy_no', 'policyholder_name', 'policyholder_phone'],
include: [
{
model: LivestockType,
as: 'livestock_type',
attributes: ['id', 'name']
}
]
},
{
model: User,
as: 'creator',
attributes: ['id', 'real_name', 'username']
},
{
model: User,
as: 'reviewer',
attributes: ['id', 'real_name', 'username']
}
]
});
res.json(responseFormat.success(updatedClaim, '生资理赔更新成功'));
} catch (error) {
console.error('更新生资理赔错误:', error);
res.status(500).json(responseFormat.error('更新生资理赔失败'));
}
};
// 删除生资理赔
const deleteLivestockClaim = async (req, res) => {
try {
const { id } = req.params;
const claim = await LivestockClaim.findByPk(id);
if (!claim) {
return res.status(404).json(responseFormat.error('生资理赔不存在'));
}
// 检查是否可以删除(只有待处理状态的理赔可以删除)
if (claim.claim_status !== 'pending') {
return res.status(400).json(responseFormat.error('只有待处理状态的理赔可以删除'));
}
await claim.destroy();
res.json(responseFormat.success(null, '生资理赔删除成功'));
} catch (error) {
console.error('删除生资理赔错误:', error);
res.status(500).json(responseFormat.error('删除生资理赔失败'));
}
};
module.exports = {
getLivestockClaims,
createLivestockClaim,
getLivestockClaimById,
updateLivestockClaim,
deleteLivestockClaim,
reviewLivestockClaim,
updateLivestockClaimPayment,
getLivestockClaimStats

View File

@@ -0,0 +1,308 @@
# 险种管理API使用示例
## 概述
本文档提供了险种管理API的详细使用示例包括请求格式、响应格式和错误处理。
## 基础信息
- **基础URL**: `http://localhost:3000/api`
- **认证方式**: Bearer Token (JWT)
- **内容类型**: `application/json`
## 认证
所有API请求都需要在请求头中包含有效的JWT令牌
```http
Authorization: Bearer <your-jwt-token>
```
## API端点
### 1. 获取险种列表
**请求**
```http
GET /api/insurance-types?page=1&pageSize=10&name=牛&status=active
Authorization: Bearer <your-jwt-token>
```
**响应示例**
```json
{
"success": true,
"message": "获取险种列表成功",
"data": [
{
"id": 1,
"name": "牛只意外伤害保险",
"description": "为牛只提供意外伤害保障",
"applicable_livestock": "牛",
"insurance_term": "1年",
"policy_form": "团体保险",
"on_sale_status": true,
"coverage_amount_min": 1000.00,
"coverage_amount_max": 50000.00,
"premium_rate": 0.05,
"status": "active",
"add_time": "2024-01-15T08:30:00.000Z",
"created_at": "2024-01-15T08:30:00.000Z",
"updated_at": "2024-01-15T08:30:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 1,
"totalPages": 1
}
}
```
### 2. 获取单个险种详情
**请求**
```http
GET /api/insurance-types/1
Authorization: Bearer <your-jwt-token>
```
**响应示例**
```json
{
"success": true,
"message": "获取险种详情成功",
"data": {
"id": 1,
"name": "牛只意外伤害保险",
"description": "为牛只提供意外伤害保障",
"applicable_livestock": "牛",
"insurance_term": "1年",
"policy_form": "团体保险",
"on_sale_status": true,
"coverage_amount_min": 1000.00,
"coverage_amount_max": 50000.00,
"premium_rate": 0.05,
"status": "active",
"add_time": "2024-01-15T08:30:00.000Z",
"created_at": "2024-01-15T08:30:00.000Z",
"updated_at": "2024-01-15T08:30:00.000Z"
}
}
```
### 3. 创建险种
**请求**
```http
POST /api/insurance-types
Authorization: Bearer <your-jwt-token>
Content-Type: application/json
{
"name": "羊只疾病保险",
"description": "为羊只提供疾病保障",
"applicable_livestock": "羊",
"insurance_term": "1年",
"policy_form": "个体保险",
"coverage_amount_min": 500.00,
"coverage_amount_max": 20000.00,
"premium_rate": 0.08
}
```
**响应示例**
```json
{
"success": true,
"message": "创建险种成功",
"data": {
"id": 2,
"name": "羊只疾病保险",
"description": "为羊只提供疾病保障",
"applicable_livestock": "羊",
"insurance_term": "1年",
"policy_form": "个体保险",
"on_sale_status": false,
"coverage_amount_min": 500.00,
"coverage_amount_max": 20000.00,
"premium_rate": 0.08,
"status": "active",
"add_time": "2024-01-15T09:00:00.000Z",
"created_at": "2024-01-15T09:00:00.000Z",
"updated_at": "2024-01-15T09:00:00.000Z"
}
}
```
### 4. 更新险种
**请求**
```http
PUT /api/insurance-types/2
Authorization: Bearer <your-jwt-token>
Content-Type: application/json
{
"name": "羊只综合保险",
"description": "为羊只提供疾病和意外伤害综合保障",
"premium_rate": 0.10
}
```
**响应示例**
```json
{
"success": true,
"message": "更新险种成功",
"data": {
"id": 2,
"name": "羊只综合保险",
"description": "为羊只提供疾病和意外伤害综合保障",
"applicable_livestock": "羊",
"insurance_term": "1年",
"policy_form": "个体保险",
"on_sale_status": false,
"coverage_amount_min": 500.00,
"coverage_amount_max": 20000.00,
"premium_rate": 0.10,
"status": "active",
"add_time": "2024-01-15T09:00:00.000Z",
"created_at": "2024-01-15T09:00:00.000Z",
"updated_at": "2024-01-15T09:15:00.000Z"
}
}
```
### 5. 更新险种状态
**请求**
```http
PATCH /api/insurance-types/2/status
Authorization: Bearer <your-jwt-token>
Content-Type: application/json
{
"status": "active",
"on_sale_status": true
}
```
**响应示例**
```json
{
"success": true,
"message": "更新险种状态成功",
"data": {
"id": 2,
"name": "羊只综合保险",
"description": "为羊只提供疾病和意外伤害综合保障",
"applicable_livestock": "羊",
"insurance_term": "1年",
"policy_form": "个体保险",
"on_sale_status": true,
"coverage_amount_min": 500.00,
"coverage_amount_max": 20000.00,
"premium_rate": 0.10,
"status": "active",
"add_time": "2024-01-15T09:00:00.000Z",
"created_at": "2024-01-15T09:00:00.000Z",
"updated_at": "2024-01-15T09:20:00.000Z"
}
}
```
### 6. 删除险种
**请求**
```http
DELETE /api/insurance-types/2
Authorization: Bearer <your-jwt-token>
```
**响应示例**
```json
{
"success": true,
"message": "删除险种成功"
}
```
## 错误处理
### 常见错误响应
#### 401 未授权
```json
{
"success": false,
"message": "未授权访问",
"error": "Unauthorized"
}
```
#### 403 权限不足
```json
{
"success": false,
"message": "权限不足",
"error": "Forbidden"
}
```
#### 404 资源不存在
```json
{
"success": false,
"message": "险种不存在",
"error": "Not Found"
}
```
#### 400 请求参数错误
```json
{
"success": false,
"message": "险种名称已存在",
"error": "Bad Request"
}
```
#### 500 服务器内部错误
```json
{
"success": false,
"message": "服务器内部错误",
"error": "Internal Server Error"
}
```
## 查询参数说明
### 获取险种列表支持的查询参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| page | integer | 否 | 1 | 页码最小值为1 |
| pageSize | integer | 否 | 10 | 每页数量范围1-100 |
| name | string | 否 | - | 险种名称,支持模糊搜索 |
| status | string | 否 | - | 险种状态可选值active, inactive |
| applicable_livestock | string | 否 | - | 适用牲畜类型,支持模糊搜索 |
| service_area | string | 否 | - | 服务区域,支持模糊搜索 |
| on_sale_status | boolean | 否 | - | 在售状态true或false |
## 使用注意事项
1. **认证令牌**: 所有请求都需要有效的JWT令牌
2. **权限检查**: 用户需要具有相应的险种管理权限
3. **数据验证**: 创建和更新时会进行数据格式验证
4. **唯一性检查**: 险种名称必须唯一
5. **分页限制**: 每页最多返回100条记录
6. **状态管理**: 险种状态和在售状态可以独立管理
## 测试工具推荐
- **Postman**: 用于API测试和调试
- **curl**: 命令行工具,适合脚本化测试
- **Swagger UI**: 在线API文档和测试界面 (http://localhost:3000/api-docs)

View File

@@ -0,0 +1,690 @@
openapi: 3.0.0
info:
title: 险种管理API
description: 保险系统险种管理功能相关API接口文档
version: 1.0.0
contact:
name: 开发团队
email: dev@example.com
servers:
- url: http://localhost:3000/api
description: 开发环境
paths:
/insurance-types:
get:
summary: 获取险种列表
description: 分页获取险种列表,支持多种筛选条件
tags:
- 险种管理
security:
- bearerAuth: []
parameters:
- name: page
in: query
description: 页码
required: false
schema:
type: integer
default: 1
minimum: 1
- name: pageSize
in: query
description: 每页数量
required: false
schema:
type: integer
default: 10
minimum: 1
maximum: 100
- name: name
in: query
description: 险种名称(模糊搜索)
required: false
schema:
type: string
- name: status
in: query
description: 险种状态
required: false
schema:
type: string
enum: [active, inactive]
- name: applicable_livestock
in: query
description: 适用牲畜类型(模糊搜索)
required: false
schema:
type: string
- name: service_area
in: query
description: 服务区域(模糊搜索)
required: false
schema:
type: string
- name: on_sale_status
in: query
description: 在售状态
required: false
schema:
type: boolean
responses:
'200':
description: 获取成功
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "获取险种列表成功"
data:
type: array
items:
$ref: '#/components/schemas/InsuranceType'
pagination:
$ref: '#/components/schemas/Pagination'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'
post:
summary: 创建险种
description: 创建新的保险险种
tags:
- 险种管理
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateInsuranceTypeRequest'
responses:
'201':
description: 创建成功
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "创建险种成功"
data:
$ref: '#/components/schemas/InsuranceType'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'
/insurance-types/{id}:
get:
summary: 获取险种详情
description: 根据ID获取单个险种的详细信息
tags:
- 险种管理
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: 险种ID
schema:
type: integer
responses:
'200':
description: 获取成功
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "获取保险类型详情成功"
data:
$ref: '#/components/schemas/InsuranceType'
'404':
$ref: '#/components/responses/NotFound'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'
put:
summary: 更新险种
description: 更新指定ID的险种信息
tags:
- 险种管理
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: 险种ID
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateInsuranceTypeRequest'
responses:
'200':
description: 更新成功
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "更新险种成功"
data:
$ref: '#/components/schemas/InsuranceType'
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'
delete:
summary: 删除险种
description: 删除指定ID的险种
tags:
- 险种管理
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: 险种ID
schema:
type: integer
responses:
'200':
description: 删除成功
content:
application/json:
schema:
type: object
properties:
code:
type: integer
example: 200
status:
type: string
example: "success"
data:
type: null
example: null
message:
type: string
example: "删除险种成功"
timestamp:
type: string
format: date-time
example: "2025-09-28T06:28:03.150Z"
'400':
description: 删除失败 - 外键约束错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: 400
status: error
data: null
message: "该险种下存在保险申请,无法删除"
timestamp: "2025-09-28T06:28:02.881Z"
'404':
description: 险种不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: 404
status: error
data: null
message: "险种不存在"
timestamp: "2025-09-28T06:28:02.881Z"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'
/insurance-types/{id}/status:
patch:
summary: 更新险种状态
description: 更新指定ID险种的状态或在售状态
tags:
- 险种管理
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: 险种ID
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateInsuranceTypeStatusRequest'
responses:
'200':
description: 更新成功
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "更新险种状态成功"
data:
$ref: '#/components/schemas/InsuranceType'
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
InsuranceType:
type: object
properties:
id:
type: integer
description: 险种ID
example: 1
name:
type: string
description: 险种名称
example: "牛羊保险"
description:
type: string
description: 险种描述
example: "针对牛羊等大型牲畜的综合保险产品"
applicable_livestock:
type: string
description: 适用牲畜类型
example: "牛、羊"
insurance_term:
type: integer
description: 保险期限(月)
example: 12
policy_form:
type: string
description: 保单形式
example: "电子保单"
coverage_amount_min:
type: number
format: decimal
description: 最低保额
example: 1000.00
coverage_amount_max:
type: number
format: decimal
description: 最高保额
example: 50000.00
premium_rate:
type: number
format: decimal
description: 保险费率
example: 0.0350
service_area:
type: string
description: 服务区域
example: "宁夏回族自治区"
add_time:
type: string
format: date-time
description: 添加时间
example: "2024-01-15T10:30:00.000Z"
on_sale_status:
type: boolean
description: 在售状态
example: true
sort_order:
type: integer
description: 排序顺序
example: 0
remarks:
type: string
description: 备注
example: "特殊说明信息"
status:
type: string
enum: [active, inactive]
description: 险种状态
example: "active"
created_by:
type: integer
description: 创建人ID
example: 1
updated_by:
type: integer
description: 更新人ID
example: 1
created_at:
type: string
format: date-time
description: 创建时间
example: "2024-01-15T10:30:00.000Z"
updated_at:
type: string
format: date-time
description: 更新时间
example: "2024-01-15T10:30:00.000Z"
CreateInsuranceTypeRequest:
type: object
required:
- name
- coverage_amount_min
- coverage_amount_max
- premium_rate
properties:
name:
type: string
description: 险种名称
example: "牛羊保险"
minLength: 1
maxLength: 100
description:
type: string
description: 险种描述
example: "针对牛羊等大型牲畜的综合保险产品"
applicable_livestock:
type: string
description: 适用牲畜类型
example: "牛、羊"
maxLength: 100
insurance_term:
type: integer
description: 保险期限(月)
example: 12
minimum: 1
policy_form:
type: string
description: 保单形式
example: "电子保单"
maxLength: 50
coverage_amount_min:
type: number
format: decimal
description: 最低保额
example: 1000.00
minimum: 0
coverage_amount_max:
type: number
format: decimal
description: 最高保额
example: 50000.00
minimum: 0
premium_rate:
type: number
format: decimal
description: 保险费率
example: 0.0350
minimum: 0
maximum: 1
service_area:
type: string
description: 服务区域
example: "宁夏回族自治区"
add_time:
type: string
format: date-time
description: 添加时间
example: "2024-01-15T10:30:00.000Z"
on_sale_status:
type: boolean
description: 在售状态
example: true
default: true
sort_order:
type: integer
description: 排序顺序
example: 0
default: 0
remarks:
type: string
description: 备注
example: "特殊说明信息"
status:
type: string
enum: [active, inactive]
description: 险种状态
example: "active"
default: "active"
UpdateInsuranceTypeRequest:
type: object
properties:
name:
type: string
description: 险种名称
example: "牛羊保险"
minLength: 1
maxLength: 100
description:
type: string
description: 险种描述
example: "针对牛羊等大型牲畜的综合保险产品"
applicable_livestock:
type: string
description: 适用牲畜类型
example: "牛、羊"
maxLength: 100
insurance_term:
type: integer
description: 保险期限(月)
example: 12
minimum: 1
policy_form:
type: string
description: 保单形式
example: "电子保单"
maxLength: 50
coverage_amount_min:
type: number
format: decimal
description: 最低保额
example: 1000.00
minimum: 0
coverage_amount_max:
type: number
format: decimal
description: 最高保额
example: 50000.00
minimum: 0
premium_rate:
type: number
format: decimal
description: 保险费率
example: 0.0350
minimum: 0
maximum: 1
service_area:
type: string
description: 服务区域
example: "宁夏回族自治区"
add_time:
type: string
format: date-time
description: 添加时间
example: "2024-01-15T10:30:00.000Z"
on_sale_status:
type: boolean
description: 在售状态
example: true
sort_order:
type: integer
description: 排序顺序
example: 0
remarks:
type: string
description: 备注
example: "特殊说明信息"
status:
type: string
enum: [active, inactive]
description: 险种状态
example: "active"
UpdateInsuranceTypeStatusRequest:
type: object
properties:
status:
type: string
enum: [active, inactive]
description: 险种状态
example: "active"
on_sale_status:
type: boolean
description: 在售状态
example: true
Pagination:
type: object
properties:
page:
type: integer
description: 当前页码
example: 1
limit:
type: integer
description: 每页数量
example: 10
total:
type: integer
description: 总记录数
example: 100
responses:
BadRequest:
description: 请求参数错误
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: false
message:
type: string
example: "请求参数错误"
Unauthorized:
description: 未授权
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: false
message:
type: string
example: "未授权访问"
Forbidden:
description: 权限不足
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: false
message:
type: string
example: "权限不足"
NotFound:
description: 资源不存在
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: false
message:
type: string
example: "险种不存在"
InternalServerError:
description: 服务器内部错误
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
example: false
message:
type: string
example: "服务器内部错误"

View File

@@ -29,6 +29,38 @@ const InsuranceType = sequelize.define('InsuranceType', {
allowNull: true,
comment: '保险类型描述'
},
applicable_livestock: {
type: DataTypes.STRING(100),
allowNull: true,
comment: '适用生资(如:牛、羊、猪等)'
},
insurance_term: {
type: DataTypes.INTEGER,
allowNull: true,
comment: '保险期限(月)',
validate: {
min: {
args: [1],
msg: '保险期限不能小于1个月'
}
}
},
policy_form: {
type: DataTypes.STRING(50),
allowNull: true,
comment: '保单形式(电子保单、纸质保单等)'
},
on_sale_status: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '在售状态true-在售false-停售)'
},
add_time: {
type: DataTypes.DATE,
allowNull: true,
comment: '添加时间'
},
coverage_amount_min: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,

View File

@@ -19,6 +19,21 @@ const LivestockClaim = sequelize.define('LivestockClaim', {
}
}
},
reporter_name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: '报案人姓名'
},
contact_phone: {
type: DataTypes.STRING(20),
allowNull: false,
comment: '联系电话'
},
policy_no: {
type: DataTypes.STRING(50),
allowNull: false,
comment: '保单号'
},
policy_id: {
type: DataTypes.INTEGER,
allowNull: false,
@@ -29,9 +44,32 @@ const LivestockClaim = sequelize.define('LivestockClaim', {
}
},
claim_type: {
type: DataTypes.ENUM('disease', 'natural_disaster', 'accident', 'theft', 'other'),
type: DataTypes.ENUM('death', 'disease', 'accident', 'natural_disaster'),
allowNull: false,
comment: '理赔类型disease-疾病natural_disaster-自然灾害accident-意外事故theft-盗窃other-其他'
comment: '理赔类型:death-死亡,disease-疾病,accident-意外事故,natural_disaster-自然灾害'
},
affected_count: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
comment: '受影响牲畜数量',
validate: {
min: {
args: [1],
msg: '受影响牲畜数量不能小于1'
}
}
},
claim_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
comment: '理赔金额',
validate: {
min: {
args: [0],
msg: '理赔金额不能小于0'
}
}
},
incident_date: {
type: DataTypes.DATE,
@@ -63,28 +101,6 @@ const LivestockClaim = sequelize.define('LivestockClaim', {
}
}
},
affected_count: {
type: DataTypes.INTEGER,
allowNull: false,
comment: '受影响牲畜数量',
validate: {
min: {
args: [1],
msg: '受影响牲畜数量不能小于1'
}
}
},
claim_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: false,
comment: '理赔金额',
validate: {
min: {
args: [0],
msg: '理赔金额不能小于0'
}
}
},
claim_status: {
type: DataTypes.ENUM('pending', 'investigating', 'approved', 'rejected', 'paid'),
allowNull: false,
@@ -100,15 +116,10 @@ const LivestockClaim = sequelize.define('LivestockClaim', {
key: 'id'
}
},
investigation_notes: {
investigation_report: {
type: DataTypes.TEXT,
allowNull: true,
comment: '调查备注'
},
investigation_date: {
type: DataTypes.DATE,
allowNull: true,
comment: '调查日期'
comment: '调查报告'
},
reviewer_id: {
type: DataTypes.INTEGER,
@@ -129,7 +140,12 @@ const LivestockClaim = sequelize.define('LivestockClaim', {
allowNull: true,
comment: '审核日期'
},
settlement_amount: {
payment_date: {
type: DataTypes.DATE,
allowNull: true,
comment: '赔付日期'
},
payment_amount: {
type: DataTypes.DECIMAL(15, 2),
allowNull: true,
comment: '实际赔付金额',
@@ -140,11 +156,6 @@ const LivestockClaim = sequelize.define('LivestockClaim', {
}
}
},
settlement_date: {
type: DataTypes.DATE,
allowNull: true,
comment: '赔付日期'
},
supporting_documents: {
type: DataTypes.JSON,
allowNull: true,

View File

@@ -212,7 +212,7 @@ LivestockClaim.belongsTo(User, {
as: 'creator'
});
LivestockClaim.belongsTo(User, {
foreignKey: 'reviewed_by',
foreignKey: 'reviewer_id',
as: 'reviewer'
});

View File

@@ -3,34 +3,328 @@ const router = express.Router();
const insuranceTypeController = require('../controllers/insuranceTypeController');
const { jwtAuth, checkPermission } = require('../middleware/auth');
/**
* @swagger
* /api/insurance-types:
* get:
* summary: 获取险种列表
* description: 分页获取险种列表,支持多种筛选条件
* tags:
* - 险种管理
* security:
* - bearerAuth: []
* parameters:
* - name: page
* in: query
* description: 页码
* required: false
* schema:
* type: integer
* default: 1
* minimum: 1
* - name: pageSize
* in: query
* description: 每页数量
* required: false
* schema:
* type: integer
* default: 10
* minimum: 1
* maximum: 100
* - name: name
* in: query
* description: 险种名称(模糊搜索)
* required: false
* schema:
* type: string
* - name: status
* in: query
* description: 险种状态
* required: false
* schema:
* type: string
* enum: [active, inactive]
* - name: applicable_livestock
* in: query
* description: 适用牲畜类型(模糊搜索)
* required: false
* schema:
* type: string
* - name: service_area
* in: query
* description: 服务区域(模糊搜索)
* required: false
* schema:
* type: string
* - name: on_sale_status
* in: query
* description: 在售状态
* required: false
* schema:
* type: boolean
* responses:
* '200':
* description: 获取成功
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "获取险种列表成功"
* data:
* type: array
* items:
* $ref: '#/components/schemas/InsuranceType'
* pagination:
* $ref: '#/components/schemas/Pagination'
* '401':
* $ref: '#/components/responses/UnauthorizedError'
* '403':
* $ref: '#/components/responses/ForbiddenError'
* '500':
* description: 服务器内部错误
* post:
* summary: 创建险种
* description: 创建新的保险险种
* tags:
* - 险种管理
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateInsuranceTypeRequest'
* responses:
* '201':
* description: 创建成功
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "创建险种成功"
* data:
* $ref: '#/components/schemas/InsuranceType'
* '400':
* description: 请求参数错误
* '401':
* $ref: '#/components/responses/UnauthorizedError'
* '403':
* $ref: '#/components/responses/ForbiddenError'
* '500':
* description: 服务器内部错误
*/
// 获取险种列表
router.get('/', jwtAuth, checkPermission('insurance_type', 'read'),
insuranceTypeController.getInsuranceTypes
);
/**
* @swagger
* /api/insurance-types/{id}:
* get:
* summary: 获取单个险种
* description: 根据ID获取险种详细信息
* tags:
* - 险种管理
* security:
* - bearerAuth: []
* parameters:
* - name: id
* in: path
* description: 险种ID
* required: true
* schema:
* type: integer
* responses:
* '200':
* description: 获取成功
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "获取险种详情成功"
* data:
* $ref: '#/components/schemas/InsuranceType'
* '401':
* $ref: '#/components/responses/UnauthorizedError'
* '403':
* $ref: '#/components/responses/ForbiddenError'
* '404':
* description: 险种不存在
* '500':
* description: 服务器内部错误
*/
// 获取单个险种详情
router.get('/:id', jwtAuth, checkPermission('insurance_type', 'read'),
insuranceTypeController.getInsuranceTypeById
);
// 创建险种
router.post('/', jwtAuth, checkPermission('insurance_type', 'create'),
insuranceTypeController.createInsuranceType
);
router.post('/', jwtAuth, checkPermission('insurance_type', 'create'), insuranceTypeController.createInsuranceType);
/**
* @swagger
* /api/insurance-types/{id}:
* put:
* summary: 更新险种
* description: 根据ID更新险种信息
* tags:
* - 险种管理
* security:
* - bearerAuth: []
* parameters:
* - name: id
* in: path
* description: 险种ID
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateInsuranceTypeRequest'
* responses:
* '200':
* description: 更新成功
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "更新险种成功"
* data:
* $ref: '#/components/schemas/InsuranceType'
* '400':
* description: 请求参数错误
* '401':
* $ref: '#/components/responses/UnauthorizedError'
* '403':
* $ref: '#/components/responses/ForbiddenError'
* '404':
* description: 险种不存在
* '500':
* description: 服务器内部错误
* delete:
* summary: 删除险种
* description: 根据ID删除险种
* tags:
* - 险种管理
* security:
* - bearerAuth: []
* parameters:
* - name: id
* in: path
* description: 险种ID
* required: true
* schema:
* type: integer
* responses:
* '200':
* description: 删除成功
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "删除险种成功"
* '401':
* $ref: '#/components/responses/UnauthorizedError'
* '403':
* $ref: '#/components/responses/ForbiddenError'
* '404':
* description: 险种不存在
* '500':
* description: 服务器内部错误
*/
// 更新险种
router.put('/:id', jwtAuth, checkPermission('insurance_type', 'update'),
insuranceTypeController.updateInsuranceType
);
router.put('/:id', jwtAuth, checkPermission('insurance_type', 'update'), insuranceTypeController.updateInsuranceType);
// 删除险种
router.delete('/:id', jwtAuth, checkPermission('insurance_type', 'delete'),
insuranceTypeController.deleteInsuranceType
);
router.delete('/:id', jwtAuth, checkPermission('insurance_type', 'delete'), insuranceTypeController.deleteInsuranceType);
/**
* @swagger
* /api/insurance-types/{id}/status:
* patch:
* summary: 更新险种状态
* description: 更新险种的状态和在售状态
* tags:
* - 险种管理
* security:
* - bearerAuth: []
* parameters:
* - name: id
* in: path
* description: 险种ID
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateInsuranceTypeStatusRequest'
* responses:
* '200':
* description: 更新成功
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* message:
* type: string
* example: "更新险种状态成功"
* data:
* $ref: '#/components/schemas/InsuranceType'
* '400':
* description: 请求参数错误
* '401':
* $ref: '#/components/responses/UnauthorizedError'
* '403':
* $ref: '#/components/responses/ForbiddenError'
* '404':
* description: 险种不存在
* '500':
* description: 服务器内部错误
*/
// 更新险种状态
router.patch('/:id/status', jwtAuth, checkPermission('insurance_type', 'update'),
insuranceTypeController.updateInsuranceTypeStatus
);
router.patch('/:id/status', jwtAuth, checkPermission('insurance_type', 'update'), insuranceTypeController.updateInsuranceTypeStatus);
module.exports = router;

View File

@@ -6,10 +6,27 @@ const {
getLivestockClaimById,
reviewLivestockClaim,
updateLivestockClaimPayment,
getLivestockClaimStats
getLivestockClaimStats,
updateLivestockClaim,
deleteLivestockClaim
} = require('../controllers/livestockClaimController');
const { authenticateToken, requirePermission } = require('../middleware/auth');
// 测试查询参数端点(无认证)
router.get('/test-params', (req, res) => {
console.log('🧪 [测试端点] 查询参数测试:');
console.log('req.url:', req.url);
console.log('req.originalUrl:', req.originalUrl);
console.log('req.query:', req.query);
console.log('req.params:', req.params);
res.json({
url: req.url,
originalUrl: req.originalUrl,
query: req.query,
params: req.params
});
});
// 获取生资理赔列表
router.get('/', authenticateToken, requirePermission('livestock_claim:read'), getLivestockClaims);
@@ -22,6 +39,12 @@ router.get('/:id', authenticateToken, requirePermission('livestock_claim:read'),
// 创建生资理赔申请
router.post('/', authenticateToken, requirePermission('livestock_claim:create'), createLivestockClaim);
// 更新生资理赔信息
router.put('/:id', authenticateToken, requirePermission('livestock_claim:update'), updateLivestockClaim);
// 删除生资理赔
router.delete('/:id', authenticateToken, requirePermission('livestock_claim:delete'), deleteLivestockClaim);
// 审核生资理赔
router.patch('/:id/review', authenticateToken, requirePermission('livestock_claim:review'), reviewLivestockClaim);

View File

@@ -0,0 +1,23 @@
const axios = require('axios');
async function testParams() {
try {
console.log('🧪 测试查询参数传递...');
const response = await axios.get('http://localhost:3000/api/livestock-claims/test-params', {
params: {
claim_no: 'LC2024001',
page: 1,
pageSize: 10
}
});
console.log('✅ 测试端点响应:');
console.log(JSON.stringify(response.data, null, 2));
} catch (error) {
console.error('❌ 测试失败:', error.response?.data || error.message);
}
}
testParams();

View File

@@ -0,0 +1,282 @@
/**
* 险种管理API测试脚本
* 使用Node.js和axios进行API测试
*/
const axios = require('axios');
// 配置
const BASE_URL = 'http://localhost:3000/api';
const TEST_USER = {
username: 'admin',
password: '123456'
};
let authToken = '';
let testInsuranceTypeId = null;
// 创建axios实例
const api = axios.create({
baseURL: BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器 - 添加认证头
api.interceptors.request.use(config => {
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
}
return config;
});
// 响应拦截器 - 处理错误
api.interceptors.response.use(
response => response,
error => {
console.error('API请求错误:', error.response?.data || error.message);
return Promise.reject(error);
}
);
// 测试函数
async function runTests() {
console.log('🚀 开始险种管理API测试...\n');
try {
// 1. 用户登录
await testLogin();
// 2. 获取险种列表
await testGetInsuranceTypes();
// 3. 创建险种
await testCreateInsuranceType();
// 4. 获取单个险种
await testGetInsuranceTypeById();
// 5. 更新险种
await testUpdateInsuranceType();
// 6. 更新险种状态
await testUpdateInsuranceTypeStatus();
// 7. 删除险种
await testDeleteInsuranceType();
console.log('✅ 所有测试完成!');
} catch (error) {
console.error('❌ 测试失败:', error.message);
process.exit(1);
}
}
// 1. 测试用户登录
async function testLogin() {
console.log('📝 测试用户登录...');
try {
const response = await api.post('/auth/login', TEST_USER);
if (response.data.success && response.data.data.token) {
authToken = response.data.data.token;
console.log('✅ 登录成功');
} else {
throw new Error('登录失败未获取到token');
}
} catch (error) {
throw new Error(`登录失败: ${error.response?.data?.message || error.message}`);
}
console.log('');
}
// 2. 测试获取险种列表
async function testGetInsuranceTypes() {
console.log('📋 测试获取险种列表...');
try {
const response = await api.get('/insurance-types', {
params: {
page: 1,
pageSize: 10,
status: 'active'
}
});
if (response.data.success) {
console.log(`✅ 获取险种列表成功,共 ${response.data.pagination.total} 条记录`);
console.log(` 当前页: ${response.data.pagination.page}/${response.data.pagination.totalPages}`);
} else {
throw new Error('获取险种列表失败');
}
} catch (error) {
throw new Error(`获取险种列表失败: ${error.response?.data?.message || error.message}`);
}
console.log('');
}
// 3. 测试创建险种
async function testCreateInsuranceType() {
console.log(' 测试创建险种...');
const testData = {
name: `测试险种_${Date.now()}`,
description: '这是一个测试用的险种',
applicable_livestock: '牛',
insurance_term: '1年',
policy_form: '团体保险',
coverage_amount_min: 1000.00,
coverage_amount_max: 50000.00,
premium_rate: 0.05
};
try {
const response = await api.post('/insurance-types', testData);
if (response.data.success && response.data.data.id) {
testInsuranceTypeId = response.data.data.id;
console.log(`✅ 创建险种成功ID: ${testInsuranceTypeId}`);
console.log(` 险种名称: ${response.data.data.name}`);
} else {
throw new Error('创建险种失败');
}
} catch (error) {
throw new Error(`创建险种失败: ${error.response?.data?.message || error.message}`);
}
console.log('');
}
// 4. 测试获取单个险种
async function testGetInsuranceTypeById() {
console.log('🔍 测试获取单个险种...');
if (!testInsuranceTypeId) {
throw new Error('没有可用的测试险种ID');
}
try {
const response = await api.get(`/insurance-types/${testInsuranceTypeId}`);
if (response.data.success && response.data.data) {
console.log(`✅ 获取险种详情成功`);
console.log(` 险种名称: ${response.data.data.name}`);
console.log(` 适用牲畜: ${response.data.data.applicable_livestock}`);
console.log(` 保险期限: ${response.data.data.insurance_term}`);
} else {
throw new Error('获取险种详情失败');
}
} catch (error) {
throw new Error(`获取险种详情失败: ${error.response?.data?.message || error.message}`);
}
console.log('');
}
// 5. 测试更新险种
async function testUpdateInsuranceType() {
console.log('✏️ 测试更新险种...');
if (!testInsuranceTypeId) {
throw new Error('没有可用的测试险种ID');
}
const updateData = {
description: '这是一个更新后的测试险种描述',
premium_rate: 0.08
};
try {
const response = await api.put(`/insurance-types/${testInsuranceTypeId}`, updateData);
if (response.data.success && response.data.data) {
console.log(`✅ 更新险种成功`);
console.log(` 新描述: ${response.data.data.description}`);
console.log(` 新费率: ${response.data.data.premium_rate}`);
} else {
throw new Error('更新险种失败');
}
} catch (error) {
throw new Error(`更新险种失败: ${error.response?.data?.message || error.message}`);
}
console.log('');
}
// 6. 测试更新险种状态
async function testUpdateInsuranceTypeStatus() {
console.log('🔄 测试更新险种状态...');
if (!testInsuranceTypeId) {
throw new Error('没有可用的测试险种ID');
}
const statusData = {
status: 'active',
on_sale_status: true
};
try {
const response = await api.patch(`/insurance-types/${testInsuranceTypeId}/status`, statusData);
if (response.data.success && response.data.data) {
console.log(`✅ 更新险种状态成功`);
console.log(` 状态: ${response.data.data.status}`);
console.log(` 在售状态: ${response.data.data.on_sale_status}`);
} else {
throw new Error('更新险种状态失败');
}
} catch (error) {
throw new Error(`更新险种状态失败: ${error.response?.data?.message || error.message}`);
}
console.log('');
}
// 7. 测试删除险种
async function testDeleteInsuranceType() {
console.log('🗑️ 测试删除险种...');
if (!testInsuranceTypeId) {
throw new Error('没有可用的测试险种ID');
}
try {
const response = await api.delete(`/insurance-types/${testInsuranceTypeId}`);
if (response.data.success) {
console.log(`✅ 删除险种成功ID: ${testInsuranceTypeId}`);
} else {
throw new Error('删除险种失败');
}
} catch (error) {
throw new Error(`删除险种失败: ${error.response?.data?.message || error.message}`);
}
console.log('');
}
// 运行测试
if (require.main === module) {
runTests().catch(error => {
console.error('测试运行失败:', error.message);
process.exit(1);
});
}
module.exports = {
runTests,
testLogin,
testGetInsuranceTypes,
testCreateInsuranceType,
testGetInsuranceTypeById,
testUpdateInsuranceType,
testUpdateInsuranceTypeStatus,
testDeleteInsuranceType
};

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