生产管理系统前端 - 更新瓦力提交的产品原型到参考目录
This commit is contained in:
404
src/lib/changeHistoryMockData.ts
Normal file
404
src/lib/changeHistoryMockData.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
// 变更历史示例数据
|
||||
import { MachineryChangeHistory } from '../types/machinery';
|
||||
import { machineryStorage } from './machineryStorage';
|
||||
|
||||
/**
|
||||
* 初始化变更历史示例数据
|
||||
* 为每台农机创建真实的变更记录
|
||||
*/
|
||||
export function initializeChangeHistoryMockData() {
|
||||
// 检查是否已有变更历史数据
|
||||
const allMachinery = machineryStorage.getAllMachinery();
|
||||
if (allMachinery.length === 0) {
|
||||
console.log('⚠️ 没有农机数据,无法创建变更历史示例');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已有变更历史
|
||||
const existingHistory = allMachinery.reduce((count, m) => {
|
||||
return count + machineryStorage.getChangeHistory(m.id).length;
|
||||
}, 0);
|
||||
|
||||
if (existingHistory > 0) {
|
||||
console.log('ℹ️ 已存在变更历史数据,跳过初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const changes: MachineryChangeHistory[] = [];
|
||||
|
||||
// ==================== 农机1:约翰迪尔拖拉机变更历史 ====================
|
||||
if (allMachinery[0]) {
|
||||
const machinery1Id = allMachinery[0].id;
|
||||
|
||||
// 1. 设备状态变更
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-001`,
|
||||
machineryId: machinery1Id,
|
||||
fieldName: 'status',
|
||||
fieldLabel: '设备状态',
|
||||
oldValue: '正常',
|
||||
newValue: '待维护',
|
||||
operator: '张三',
|
||||
operatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(), // 3天前
|
||||
});
|
||||
|
||||
// 2. 当前位置变更
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-002`,
|
||||
machineryId: machinery1Id,
|
||||
fieldName: 'currentLocation',
|
||||
fieldLabel: '当前位置',
|
||||
oldValue: '1号地块',
|
||||
newValue: '3号地块',
|
||||
operator: '李四',
|
||||
operatedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(), // 5天前
|
||||
});
|
||||
|
||||
// 3. 操作人员变更
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-003`,
|
||||
machineryId: machinery1Id,
|
||||
fieldName: 'operator',
|
||||
fieldLabel: '操作人员',
|
||||
oldValue: '张三',
|
||||
newValue: '王五',
|
||||
operator: '系统管理员',
|
||||
operatedAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7天前
|
||||
});
|
||||
|
||||
// 4. 保险信息更新
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-004`,
|
||||
machineryId: machinery1Id,
|
||||
fieldName: 'insuranceEndDate',
|
||||
fieldLabel: '保险结束日期',
|
||||
oldValue: '2025-03-31',
|
||||
newValue: '2026-03-31',
|
||||
operator: '财务部-刘会计',
|
||||
operatedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(), // 10天前
|
||||
});
|
||||
|
||||
// 5. 购机价格调整
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-005`,
|
||||
machineryId: machinery1Id,
|
||||
fieldName: 'purchasePrice',
|
||||
fieldLabel: '购机价格',
|
||||
oldValue: 350000,
|
||||
newValue: 345000,
|
||||
operator: '财务部-刘会计',
|
||||
operatedAt: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000).toISOString(), // 15天前
|
||||
});
|
||||
|
||||
// 6. 备注更新
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-006`,
|
||||
machineryId: machinery1Id,
|
||||
fieldName: 'remarks',
|
||||
fieldLabel: '备注',
|
||||
oldValue: '主力耕作设备,状态良好',
|
||||
newValue: '主力耕作设备,状态良好,已完成季度保养',
|
||||
operator: '张三',
|
||||
operatedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(), // 20天前
|
||||
});
|
||||
|
||||
// 7. 设备状态恢复正常
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-007`,
|
||||
machineryId: machinery1Id,
|
||||
fieldName: 'status',
|
||||
fieldLabel: '设备状态',
|
||||
oldValue: '待维护',
|
||||
newValue: '正常',
|
||||
operator: '维修班-李师傅',
|
||||
operatedAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString(), // 1天前
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 农机2:收割机变更历史 ====================
|
||||
if (allMachinery[1]) {
|
||||
const machinery2Id = allMachinery[1].id;
|
||||
|
||||
// 1. 设备名称规范化
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-008`,
|
||||
machineryId: machinery2Id,
|
||||
fieldName: 'name',
|
||||
fieldLabel: '设备名称',
|
||||
oldValue: '久保田收割机',
|
||||
newValue: '久保田PRO988Q收割机',
|
||||
operator: '资产管理员',
|
||||
operatedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30天前
|
||||
});
|
||||
|
||||
// 2. 作业前位置更新
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-009`,
|
||||
machineryId: machinery2Id,
|
||||
fieldName: 'currentLocation',
|
||||
fieldLabel: '当前位置',
|
||||
oldValue: '机库A区',
|
||||
newValue: '东片收割区',
|
||||
operator: '李四',
|
||||
operatedAt: new Date(now.getTime() - 25 * 24 * 60 * 60 * 1000).toISOString(), // 25天前
|
||||
});
|
||||
|
||||
// 3. 作业后位置更新
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-010`,
|
||||
machineryId: machinery2Id,
|
||||
fieldName: 'currentLocation',
|
||||
fieldLabel: '当前位置',
|
||||
oldValue: '东片收割区',
|
||||
newValue: '机库A区',
|
||||
operator: '李四',
|
||||
operatedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(), // 20天前
|
||||
});
|
||||
|
||||
// 4. 设备状态因故障变更
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-011`,
|
||||
machineryId: machinery2Id,
|
||||
fieldName: 'status',
|
||||
fieldLabel: '设备状态',
|
||||
oldValue: '正常',
|
||||
newValue: '待维护',
|
||||
operator: '李四',
|
||||
operatedAt: new Date(now.getTime() - 18 * 24 * 60 * 60 * 1000).toISOString(), // 18天前
|
||||
});
|
||||
|
||||
// 5. 维修完成,状态恢复
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-012`,
|
||||
machineryId: machinery2Id,
|
||||
fieldName: 'status',
|
||||
fieldLabel: '设备状态',
|
||||
oldValue: '待维护',
|
||||
newValue: '正常',
|
||||
operator: '维修班-赵师傅',
|
||||
operatedAt: new Date(now.getTime() - 16 * 24 * 60 * 60 * 1000).toISOString(), // 16天前
|
||||
});
|
||||
|
||||
// 6. 标签更新
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-013`,
|
||||
machineryId: machinery2Id,
|
||||
fieldName: 'tags',
|
||||
fieldLabel: '标签',
|
||||
oldValue: ['tag-2', 'tag-3'],
|
||||
newValue: ['tag-1', 'tag-2', 'tag-3'],
|
||||
operator: '系统管理员',
|
||||
operatedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString(), // 12天前
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 农机3:播种机变更历史 ====================
|
||||
if (allMachinery[2]) {
|
||||
const machinery3Id = allMachinery[2].id;
|
||||
|
||||
// 1. 操作人员调整
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-014`,
|
||||
machineryId: machinery3Id,
|
||||
fieldName: 'operator',
|
||||
fieldLabel: '操作人员',
|
||||
oldValue: '王五',
|
||||
newValue: '赵六',
|
||||
operator: '系统管理员',
|
||||
operatedAt: new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000).toISOString(), // 40天前
|
||||
});
|
||||
|
||||
// 2. 播种前检修
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-015`,
|
||||
machineryId: machinery3Id,
|
||||
fieldName: 'status',
|
||||
fieldLabel: '设备状态',
|
||||
oldValue: '正常',
|
||||
newValue: '待维护',
|
||||
operator: '赵六',
|
||||
operatedAt: new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000).toISOString(), // 35天前
|
||||
});
|
||||
|
||||
// 3. 检修完成
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-016`,
|
||||
machineryId: machinery3Id,
|
||||
fieldName: 'status',
|
||||
fieldLabel: '设备状态',
|
||||
oldValue: '待维护',
|
||||
newValue: '正常',
|
||||
operator: '维修班-孙师傅',
|
||||
operatedAt: new Date(now.getTime() - 33 * 24 * 60 * 60 * 1000).toISOString(), // 33天前
|
||||
});
|
||||
|
||||
// 4. 作业地点变更
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-017`,
|
||||
machineryId: machinery3Id,
|
||||
fieldName: 'currentLocation',
|
||||
fieldLabel: '当前位置',
|
||||
oldValue: '机库B区',
|
||||
newValue: '西片播种区',
|
||||
operator: '赵六',
|
||||
operatedAt: new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000).toISOString(), // 28天前
|
||||
});
|
||||
|
||||
// 5. 作业完成归库
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-018`,
|
||||
machineryId: machinery3Id,
|
||||
fieldName: 'currentLocation',
|
||||
fieldLabel: '当前位置',
|
||||
oldValue: '西片播种区',
|
||||
newValue: '机库B区',
|
||||
operator: '赵六',
|
||||
operatedAt: new Date(now.getTime() - 22 * 24 * 60 * 60 * 1000).toISOString(), // 22天前
|
||||
});
|
||||
|
||||
// 6. 保险金额调整
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-019`,
|
||||
machineryId: machinery3Id,
|
||||
fieldName: 'insuranceAmount',
|
||||
fieldLabel: '保险金额',
|
||||
oldValue: 150000,
|
||||
newValue: 160000,
|
||||
operator: '财务部-刘会计',
|
||||
operatedAt: new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000).toISOString(), // 8天前
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 农机4:植保无人机变更历史 ====================
|
||||
if (allMachinery[3]) {
|
||||
const machinery4Id = allMachinery[3].id;
|
||||
|
||||
// 1. 电池更换记录
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-020`,
|
||||
machineryId: machinery4Id,
|
||||
fieldName: 'remarks',
|
||||
fieldLabel: '备注',
|
||||
oldValue: '最新款植保无人机,智能化程度高',
|
||||
newValue: '最新款植保无人机,智能化程度高,电池已更换',
|
||||
operator: '无人机操作员-周七',
|
||||
operatedAt: new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString(), // 6天前
|
||||
});
|
||||
|
||||
// 2. 作业区域变更
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-021`,
|
||||
machineryId: machinery4Id,
|
||||
fieldName: 'currentLocation',
|
||||
fieldLabel: '当前位置',
|
||||
oldValue: '设备间',
|
||||
newValue: '北片植保区',
|
||||
operator: '周七',
|
||||
operatedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(), // 4天前
|
||||
});
|
||||
|
||||
// 3. 标签添加
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-022`,
|
||||
machineryId: machinery4Id,
|
||||
fieldName: 'tags',
|
||||
fieldLabel: '标签',
|
||||
oldValue: ['tag-1', 'tag-2', 'tag-3'],
|
||||
newValue: ['tag-1', 'tag-2', 'tag-3', 'tag-4'],
|
||||
operator: '系统管理员',
|
||||
operatedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(), // 2天前
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 农机5:拖拉机变更历史 ====================
|
||||
if (allMachinery[4]) {
|
||||
const machinery5Id = allMachinery[4].id;
|
||||
|
||||
// 1. 供应商信息更正
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-023`,
|
||||
machineryId: machinery5Id,
|
||||
fieldName: 'supplier',
|
||||
fieldLabel: '供应商',
|
||||
oldValue: '农机经销商',
|
||||
newValue: '雷沃重工授权经销商',
|
||||
operator: '采购部-吴采购',
|
||||
operatedAt: new Date(now.getTime() - 50 * 24 * 60 * 60 * 1000).toISOString(), // 50天前
|
||||
});
|
||||
|
||||
// 2. 部门调整
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-024`,
|
||||
machineryId: machinery5Id,
|
||||
fieldName: 'department',
|
||||
fieldLabel: '所属部门',
|
||||
oldValue: '第三生产队',
|
||||
newValue: '第一生产队',
|
||||
operator: '人事部-郑主管',
|
||||
operatedAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(), // 45天前
|
||||
});
|
||||
|
||||
// 3. 年检完成
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-025`,
|
||||
machineryId: machinery5Id,
|
||||
fieldName: 'remarks',
|
||||
fieldLabel: '备注',
|
||||
oldValue: '可靠耐用,适合大田作业',
|
||||
newValue: '可靠耐用,适合大田作业,已完成年检',
|
||||
operator: '安全员-冯安全',
|
||||
operatedAt: new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString(), // 14天前
|
||||
});
|
||||
}
|
||||
|
||||
// 保存所有变更记录
|
||||
changes.forEach(change => {
|
||||
machineryStorage.saveChangeHistory(change);
|
||||
});
|
||||
|
||||
console.log(`✅ 已初始化 ${changes.length} 条变更历史示例数据`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有变更历史(仅用于测试)
|
||||
*/
|
||||
export function clearChangeHistory() {
|
||||
localStorage.removeItem('smart_agriculture_machinery_history');
|
||||
console.log('🗑️ 已清除所有变更历史');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取变更历史统计信息
|
||||
*/
|
||||
export function getChangeHistoryStatistics() {
|
||||
const allMachinery = machineryStorage.getAllMachinery();
|
||||
|
||||
let totalChanges = 0;
|
||||
const changesByMachinery: Record<string, number> = {};
|
||||
const changesByField: Record<string, number> = {};
|
||||
const changesByOperator: Record<string, number> = {};
|
||||
|
||||
allMachinery.forEach(machinery => {
|
||||
const changes = machineryStorage.getChangeHistory(machinery.id);
|
||||
totalChanges += changes.length;
|
||||
changesByMachinery[machinery.name] = changes.length;
|
||||
|
||||
changes.forEach(change => {
|
||||
// 按字段统计
|
||||
changesByField[change.fieldLabel] = (changesByField[change.fieldLabel] || 0) + 1;
|
||||
|
||||
// 按操作人统计
|
||||
changesByOperator[change.operator] = (changesByOperator[change.operator] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
totalChanges,
|
||||
totalMachinery: allMachinery.length,
|
||||
changesByMachinery,
|
||||
changesByField,
|
||||
changesByOperator,
|
||||
avgChangesPerMachinery: allMachinery.length > 0 ? totalChanges / allMachinery.length : 0,
|
||||
};
|
||||
}
|
||||
239
src/lib/changeTracker.ts
Normal file
239
src/lib/changeTracker.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// 变更追踪工具
|
||||
import { MachineryRecord, MachineryChangeHistory } from '../types/machinery';
|
||||
|
||||
// 字段名称到中文标签的映射
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
// 基本信息
|
||||
name: '设备名称',
|
||||
model: '型号规格',
|
||||
category: '农机类型',
|
||||
usage: '使用场景',
|
||||
manufacturer: '生产厂家',
|
||||
manufactureDate: '出厂日期',
|
||||
purchaseDate: '购买日期',
|
||||
|
||||
// 技术参数
|
||||
engineNumber: '发动机号',
|
||||
chassisNumber: '车架号',
|
||||
power: '额定功率',
|
||||
weight: '整机重量',
|
||||
workingWidth: '工作幅宽',
|
||||
|
||||
// 购机信息
|
||||
purchasePrice: '购机价格',
|
||||
supplier: '供应商',
|
||||
invoiceNumber: '发票号码',
|
||||
invoiceUrl: '购机发票',
|
||||
|
||||
// 保险信息
|
||||
insuranceCompany: '保险公司',
|
||||
insurancePolicyNumber: '保单号',
|
||||
insuranceStartDate: '保险起始日期',
|
||||
insuranceEndDate: '保险结束日期',
|
||||
insuranceAmount: '保险金额',
|
||||
|
||||
// 使用信息
|
||||
status: '设备状态',
|
||||
currentLocation: '当前位置',
|
||||
operator: '操作人员',
|
||||
department: '所属部门',
|
||||
|
||||
// 保养信息
|
||||
maintenanceCycle: '保养周期',
|
||||
maintenanceCycleUnit: '保养周期单位',
|
||||
|
||||
// 其他信息
|
||||
remarks: '备注',
|
||||
tags: '标签',
|
||||
};
|
||||
|
||||
// 需要排除的字段(不记录变更)
|
||||
const EXCLUDED_FIELDS = [
|
||||
'id',
|
||||
'qrCode',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
];
|
||||
|
||||
/**
|
||||
* 格式化字段值用于显示
|
||||
*/
|
||||
export function formatFieldValue(fieldName: string, value: any): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '(空)';
|
||||
}
|
||||
|
||||
// 日期字段
|
||||
if (fieldName.includes('Date') || fieldName.includes('Time')) {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString('zh-CN');
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 金额字段
|
||||
if (fieldName.includes('Price') || fieldName.includes('Amount')) {
|
||||
const num = parseFloat(value);
|
||||
return isNaN(num) ? String(value) : `¥${num.toLocaleString()}`;
|
||||
}
|
||||
|
||||
// 保养周期单位
|
||||
if (fieldName === 'maintenanceCycleUnit') {
|
||||
const unitMap: Record<string, string> = {
|
||||
day: '天',
|
||||
month: '月',
|
||||
year: '年'
|
||||
};
|
||||
return unitMap[value] || String(value);
|
||||
}
|
||||
|
||||
// 数组字段(如标签)
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.join(', ') : '(空)';
|
||||
}
|
||||
|
||||
// 对象字段
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// 布尔值
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个值是否相同
|
||||
*/
|
||||
function isEqual(oldValue: any, newValue: any): boolean {
|
||||
// 处理null/undefined/空字符串的情况
|
||||
const isOldEmpty = oldValue === null || oldValue === undefined || oldValue === '';
|
||||
const isNewEmpty = newValue === null || newValue === undefined || newValue === '';
|
||||
|
||||
if (isOldEmpty && isNewEmpty) return true;
|
||||
if (isOldEmpty !== isNewEmpty) return false;
|
||||
|
||||
// 数组比较
|
||||
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
|
||||
if (oldValue.length !== newValue.length) return false;
|
||||
return oldValue.every((val, index) => val === newValue[index]);
|
||||
}
|
||||
|
||||
// 对象比较
|
||||
if (typeof oldValue === 'object' && typeof newValue === 'object') {
|
||||
return JSON.stringify(oldValue) === JSON.stringify(newValue);
|
||||
}
|
||||
|
||||
// 基本类型比较
|
||||
return oldValue === newValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪农机档案的变更
|
||||
* @param oldRecord 旧记录
|
||||
* @param newRecord 新记录
|
||||
* @param operator 操作人
|
||||
* @returns 变更历史记录数组
|
||||
*/
|
||||
export function trackMachineryChanges(
|
||||
oldRecord: MachineryRecord | undefined,
|
||||
newRecord: Partial<MachineryRecord>,
|
||||
operator: string
|
||||
): MachineryChangeHistory[] {
|
||||
const changes: MachineryChangeHistory[] = [];
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// 如果是新建,不记录变更
|
||||
if (!oldRecord) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 遍历新记录的所有字段
|
||||
Object.keys(newRecord).forEach((fieldName) => {
|
||||
// 跳过排除的字段
|
||||
if (EXCLUDED_FIELDS.includes(fieldName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldValue = (oldRecord as any)[fieldName];
|
||||
const newValue = (newRecord as any)[fieldName];
|
||||
|
||||
// 检查值是否发生变化
|
||||
if (!isEqual(oldValue, newValue)) {
|
||||
const fieldLabel = FIELD_LABELS[fieldName] || fieldName;
|
||||
|
||||
changes.push({
|
||||
id: `change-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
machineryId: oldRecord.id,
|
||||
fieldName,
|
||||
fieldLabel,
|
||||
oldValue,
|
||||
newValue,
|
||||
operator,
|
||||
operatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段的变更描述
|
||||
*/
|
||||
export function getChangeDescription(change: MachineryChangeHistory): string {
|
||||
const oldValueStr = formatFieldValue(change.fieldName, change.oldValue);
|
||||
const newValueStr = formatFieldValue(change.fieldName, change.newValue);
|
||||
|
||||
return `将 ${change.fieldLabel} 从 "${oldValueStr}" 修改为 "${newValueStr}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期分组变更记录
|
||||
*/
|
||||
export function groupChangesByDate(
|
||||
changes: MachineryChangeHistory[]
|
||||
): Record<string, MachineryChangeHistory[]> {
|
||||
const grouped: Record<string, MachineryChangeHistory[]> = {};
|
||||
|
||||
changes.forEach(change => {
|
||||
const date = new Date(change.operatedAt).toLocaleDateString('zh-CN');
|
||||
if (!grouped[date]) {
|
||||
grouped[date] = [];
|
||||
}
|
||||
grouped[date].push(change);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取变更统计信息
|
||||
*/
|
||||
export function getChangeStats(changes: MachineryChangeHistory[]) {
|
||||
const stats = {
|
||||
total: changes.length,
|
||||
byField: {} as Record<string, number>,
|
||||
byOperator: {} as Record<string, number>,
|
||||
recentChanges: changes
|
||||
.sort((a, b) => new Date(b.operatedAt).getTime() - new Date(a.operatedAt).getTime())
|
||||
.slice(0, 5),
|
||||
};
|
||||
|
||||
changes.forEach(change => {
|
||||
// 按字段统计
|
||||
const field = change.fieldLabel;
|
||||
stats.byField[field] = (stats.byField[field] || 0) + 1;
|
||||
|
||||
// 按操作人统计
|
||||
stats.byOperator[change.operator] = (stats.byOperator[change.operator] || 0) + 1;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
955
src/lib/cropKnowledgeBase.ts
Normal file
955
src/lib/cropKnowledgeBase.ts
Normal file
@@ -0,0 +1,955 @@
|
||||
/**
|
||||
* 作物-环境知识库
|
||||
* 定义各种作物的最适宜生长环境参数范围
|
||||
*/
|
||||
|
||||
export interface CropKnowledge {
|
||||
id: string;
|
||||
cropName: string;
|
||||
category: '粮食作物' | '经济作物' | '蔬菜作物' | '果树';
|
||||
description: string;
|
||||
|
||||
// 土壤环境要求
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [number, number]; // 最佳范围
|
||||
acceptable: [number, number]; // 可接受范围
|
||||
};
|
||||
organicMatter: {
|
||||
optimal: [number, number]; // g/kg
|
||||
acceptable: [number, number];
|
||||
};
|
||||
soilDepth: {
|
||||
optimal: [number, number]; // cm
|
||||
acceptable: [number, number];
|
||||
};
|
||||
nitrogen: {
|
||||
optimal: [number, number]; // g/kg
|
||||
acceptable: [number, number];
|
||||
};
|
||||
phosphorus: {
|
||||
optimal: [number, number]; // g/kg
|
||||
acceptable: [number, number];
|
||||
};
|
||||
potassium: {
|
||||
optimal: [number, number]; // g/kg
|
||||
acceptable: [number, number];
|
||||
};
|
||||
drainage: {
|
||||
optimal: [number, number]; // 0-100分
|
||||
acceptable: [number, number];
|
||||
};
|
||||
};
|
||||
|
||||
// 气候环境要求
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [number, number]; // °C
|
||||
acceptable: [number, number];
|
||||
};
|
||||
rainfall: {
|
||||
optimal: [number, number]; // mm/年
|
||||
acceptable: [number, number];
|
||||
};
|
||||
sunlight: {
|
||||
optimal: [number, number]; // 小时/天
|
||||
acceptable: [number, number];
|
||||
};
|
||||
};
|
||||
|
||||
// 预期产量(kg/亩)
|
||||
expectedYield: {
|
||||
high: [number, number]; // 高适宜性条件下
|
||||
medium: [number, number]; // 中等适宜性条件下
|
||||
low: [number, number]; // 低适宜性条件下
|
||||
};
|
||||
|
||||
// 生长周期
|
||||
growthCycle: {
|
||||
days: number; // 天数
|
||||
seasons: string[]; // 适宜季节
|
||||
};
|
||||
|
||||
// 风险因子
|
||||
riskFactors: {
|
||||
id: string;
|
||||
name: string;
|
||||
condition: string; // 触发条件描述
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
suggestion: string; // 应对建议
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 作物知识库数据
|
||||
*/
|
||||
export const cropKnowledgeBase: CropKnowledge[] = [
|
||||
{
|
||||
id: 'rice',
|
||||
cropName: '水稻',
|
||||
category: '粮食作物',
|
||||
description: '主要粮食作物,喜温暖湿润气候,需水量大',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [5.5, 6.5],
|
||||
acceptable: [5.0, 7.0],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [25, 40],
|
||||
acceptable: [20, 45],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [40, 80],
|
||||
acceptable: [30, 100],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [1.5, 2.5],
|
||||
acceptable: [1.0, 3.0],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [0.8, 1.5],
|
||||
acceptable: [0.6, 2.0],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [15, 25],
|
||||
acceptable: [12, 30],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [40, 70],
|
||||
acceptable: [30, 80],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [20, 30],
|
||||
acceptable: [15, 35],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [1000, 1500],
|
||||
acceptable: [800, 2000],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [6, 8],
|
||||
acceptable: [5, 10],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [500, 600],
|
||||
medium: [400, 500],
|
||||
low: [300, 400],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 120,
|
||||
seasons: ['春季', '夏季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'drought',
|
||||
name: '干旱风险',
|
||||
condition: '年降雨量 < 900mm',
|
||||
severity: 'high',
|
||||
suggestion: '建议配套灌溉设施,确保水源充足',
|
||||
},
|
||||
{
|
||||
id: 'ph_low',
|
||||
name: '土壤酸化',
|
||||
condition: 'pH < 5.5',
|
||||
severity: 'medium',
|
||||
suggestion: '建议施用石灰调节土壤pH值',
|
||||
},
|
||||
{
|
||||
id: 'temperature_low',
|
||||
name: '低温冷害',
|
||||
condition: '平均温度 < 18°C',
|
||||
severity: 'high',
|
||||
suggestion: '选择耐寒品种或调整种植时间',
|
||||
},
|
||||
{
|
||||
id: 'nitrogen_low',
|
||||
name: '氮素不足',
|
||||
condition: '全氮 < 1.2 g/kg',
|
||||
severity: 'medium',
|
||||
suggestion: '增施氮肥,建议尿素或复合肥',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'corn',
|
||||
cropName: '玉米',
|
||||
category: '粮食作物',
|
||||
description: '重要粮食作物,耐旱性强,适应性广',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [6.0, 7.5],
|
||||
acceptable: [5.5, 8.0],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [20, 35],
|
||||
acceptable: [15, 40],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [60, 100],
|
||||
acceptable: [50, 120],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [1.0, 2.0],
|
||||
acceptable: [0.8, 2.5],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [0.6, 1.2],
|
||||
acceptable: [0.4, 1.5],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [18, 28],
|
||||
acceptable: [15, 35],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [70, 90],
|
||||
acceptable: [60, 95],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [22, 30],
|
||||
acceptable: [18, 35],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [600, 1000],
|
||||
acceptable: [500, 1200],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [7, 10],
|
||||
acceptable: [6, 12],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [550, 700],
|
||||
medium: [450, 550],
|
||||
low: [350, 450],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 110,
|
||||
seasons: ['春季', '夏季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'waterlogging',
|
||||
name: '涝害风险',
|
||||
condition: '排水性 < 60分',
|
||||
severity: 'high',
|
||||
suggestion: '改善排水系统,开挖排水沟',
|
||||
},
|
||||
{
|
||||
id: 'ph_high',
|
||||
name: '土壤碱化',
|
||||
condition: 'pH > 7.8',
|
||||
severity: 'medium',
|
||||
suggestion: '施用酸性肥料或硫磺调节pH',
|
||||
},
|
||||
{
|
||||
id: 'potassium_low',
|
||||
name: '钾素缺乏',
|
||||
condition: '全钾 < 16 g/kg',
|
||||
severity: 'medium',
|
||||
suggestion: '增施钾肥,建议氯化钾或硫酸钾',
|
||||
},
|
||||
{
|
||||
id: 'high_temp',
|
||||
name: '高温胁迫',
|
||||
condition: '平均温度 > 32°C',
|
||||
severity: 'medium',
|
||||
suggestion: '适当遮阴,增加灌溉频次',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'wheat',
|
||||
cropName: '小麦',
|
||||
category: '粮食作物',
|
||||
description: '主要粮食作物,喜冷凉气候,耐寒性强',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [6.5, 7.5],
|
||||
acceptable: [6.0, 8.0],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [18, 30],
|
||||
acceptable: [15, 35],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [50, 90],
|
||||
acceptable: [40, 100],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [1.2, 2.2],
|
||||
acceptable: [0.9, 2.5],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [0.7, 1.3],
|
||||
acceptable: [0.5, 1.8],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [16, 24],
|
||||
acceptable: [14, 28],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [75, 90],
|
||||
acceptable: [65, 95],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [12, 20],
|
||||
acceptable: [5, 25],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [450, 750],
|
||||
acceptable: [350, 900],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [6, 8],
|
||||
acceptable: [5, 9],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [450, 550],
|
||||
medium: [350, 450],
|
||||
low: [250, 350],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 210,
|
||||
seasons: ['秋季', '冬季', '春季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'spring_cold',
|
||||
name: '倒春寒',
|
||||
condition: '春季平均温度 < 8°C',
|
||||
severity: 'high',
|
||||
suggestion: '选择抗寒品种,适当延迟播种',
|
||||
},
|
||||
{
|
||||
id: 'drought_spring',
|
||||
name: '春季干旱',
|
||||
condition: '春季降雨 < 100mm',
|
||||
severity: 'medium',
|
||||
suggestion: '及时灌溉,保持土壤湿润',
|
||||
},
|
||||
{
|
||||
id: 'organic_low',
|
||||
name: '有机质偏低',
|
||||
condition: '有机质 < 16 g/kg',
|
||||
severity: 'medium',
|
||||
suggestion: '增施有机肥,改良土壤结构',
|
||||
},
|
||||
{
|
||||
id: 'phosphorus_low',
|
||||
name: '磷素不足',
|
||||
condition: '全磷 < 0.6 g/kg',
|
||||
severity: 'low',
|
||||
suggestion: '基肥增施过磷酸钙',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'soybean',
|
||||
cropName: '大豆',
|
||||
category: '粮食作物',
|
||||
description: '重要油料和蛋白作物,具有固氮能力',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [6.0, 7.0],
|
||||
acceptable: [5.5, 7.5],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [22, 35],
|
||||
acceptable: [18, 40],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [45, 80],
|
||||
acceptable: [35, 90],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [0.8, 1.5],
|
||||
acceptable: [0.6, 2.0],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [0.8, 1.5],
|
||||
acceptable: [0.6, 2.0],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [18, 26],
|
||||
acceptable: [15, 30],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [70, 85],
|
||||
acceptable: [60, 90],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [20, 28],
|
||||
acceptable: [15, 32],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [500, 900],
|
||||
acceptable: [400, 1100],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [6, 9],
|
||||
acceptable: [5, 10],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [200, 280],
|
||||
medium: [160, 200],
|
||||
low: [120, 160],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 100,
|
||||
seasons: ['春季', '夏季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'waterlogging',
|
||||
name: '涝害风险',
|
||||
condition: '排水性 < 65分',
|
||||
severity: 'high',
|
||||
suggestion: '改善排水,避免积水',
|
||||
},
|
||||
{
|
||||
id: 'drought_pod',
|
||||
name: '结荚期干旱',
|
||||
condition: '夏季降雨 < 200mm',
|
||||
severity: 'medium',
|
||||
suggestion: '结荚期及时灌溉',
|
||||
},
|
||||
{
|
||||
id: 'phosphorus_critical',
|
||||
name: '磷素关键需求',
|
||||
condition: '全磷 < 0.7 g/kg',
|
||||
severity: 'medium',
|
||||
suggestion: '大豆对磷敏感,需重点补充',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'cotton',
|
||||
cropName: '棉花',
|
||||
category: '经济作物',
|
||||
description: '重要纤维作物,喜温暖干燥气候',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [6.5, 8.0],
|
||||
acceptable: [6.0, 8.5],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [18, 28],
|
||||
acceptable: [15, 32],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [60, 100],
|
||||
acceptable: [50, 120],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [1.0, 1.8],
|
||||
acceptable: [0.8, 2.2],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [0.6, 1.2],
|
||||
acceptable: [0.5, 1.5],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [20, 30],
|
||||
acceptable: [16, 35],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [80, 95],
|
||||
acceptable: [70, 100],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [25, 32],
|
||||
acceptable: [20, 35],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [500, 800],
|
||||
acceptable: [400, 1000],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [8, 12],
|
||||
acceptable: [7, 14],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [120, 160],
|
||||
medium: [90, 120],
|
||||
low: [60, 90],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 180,
|
||||
seasons: ['春季', '夏季', '秋季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'late_rain',
|
||||
name: '后期多雨',
|
||||
condition: '秋季降雨 > 300mm',
|
||||
severity: 'high',
|
||||
suggestion: '影响棉花采摘和品质,需排水',
|
||||
},
|
||||
{
|
||||
id: 'potassium_high',
|
||||
name: '钾素需求大',
|
||||
condition: '全钾 < 18 g/kg',
|
||||
severity: 'medium',
|
||||
suggestion: '棉花需钾量大,重点补充钾肥',
|
||||
},
|
||||
{
|
||||
id: 'temperature_low',
|
||||
name: '生长期温度不足',
|
||||
condition: '平均温度 < 22°C',
|
||||
severity: 'medium',
|
||||
suggestion: '延长生育期,影响产量',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'tomato',
|
||||
cropName: '番茄',
|
||||
category: '蔬菜作物',
|
||||
description: '重要蔬菜作物,喜温暖,对光照要求高',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [6.0, 7.0],
|
||||
acceptable: [5.5, 7.5],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [28, 45],
|
||||
acceptable: [22, 50],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [40, 70],
|
||||
acceptable: [30, 80],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [1.5, 2.5],
|
||||
acceptable: [1.2, 3.0],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [1.0, 2.0],
|
||||
acceptable: [0.8, 2.5],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [22, 32],
|
||||
acceptable: [18, 38],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [75, 90],
|
||||
acceptable: [65, 95],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [20, 28],
|
||||
acceptable: [15, 32],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [400, 700],
|
||||
acceptable: [300, 900],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [8, 12],
|
||||
acceptable: [6, 14],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [4000, 6000],
|
||||
medium: [3000, 4000],
|
||||
low: [2000, 3000],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 95,
|
||||
seasons: ['春季', '秋季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'humidity_high',
|
||||
name: '高湿病害',
|
||||
condition: '排水性 < 70分',
|
||||
severity: 'high',
|
||||
suggestion: '易发生病害,加强通风排湿',
|
||||
},
|
||||
{
|
||||
id: 'nitrogen_excess',
|
||||
name: '氮肥过量',
|
||||
condition: '全氮 > 2.8 g/kg',
|
||||
severity: 'medium',
|
||||
suggestion: '控制氮肥,避免徒长',
|
||||
},
|
||||
{
|
||||
id: 'temperature_extreme',
|
||||
name: '温度不适',
|
||||
condition: '温度 < 15°C 或 > 30°C',
|
||||
severity: 'medium',
|
||||
suggestion: '影响坐果,需温室栽培',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'potato',
|
||||
cropName: '马铃薯',
|
||||
category: '粮食作物',
|
||||
description: '重要粮食和蔬菜作物,喜冷凉气候',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [5.0, 6.5],
|
||||
acceptable: [4.5, 7.0],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [25, 40],
|
||||
acceptable: [20, 45],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [50, 80],
|
||||
acceptable: [40, 90],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [1.2, 2.0],
|
||||
acceptable: [1.0, 2.5],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [0.8, 1.6],
|
||||
acceptable: [0.6, 2.0],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [20, 30],
|
||||
acceptable: [16, 35],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [80, 95],
|
||||
acceptable: [70, 100],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [15, 22],
|
||||
acceptable: [10, 25],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [400, 600],
|
||||
acceptable: [300, 800],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [6, 8],
|
||||
acceptable: [5, 10],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [2500, 3500],
|
||||
medium: [1800, 2500],
|
||||
low: [1200, 1800],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 90,
|
||||
seasons: ['春季', '秋季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'waterlogging',
|
||||
name: '涝害烂薯',
|
||||
condition: '排水性 < 75分',
|
||||
severity: 'high',
|
||||
suggestion: '必须排水良好,避免积水',
|
||||
},
|
||||
{
|
||||
id: 'ph_high',
|
||||
name: 'pH偏高',
|
||||
condition: 'pH > 6.8',
|
||||
severity: 'medium',
|
||||
suggestion: '易发生疮痂病,施用硫磺调酸',
|
||||
},
|
||||
{
|
||||
id: 'temperature_high',
|
||||
name: '高温影响',
|
||||
condition: '平均温度 > 24°C',
|
||||
severity: 'medium',
|
||||
suggestion: '块茎膨大受阻,影响产量',
|
||||
},
|
||||
{
|
||||
id: 'potassium_critical',
|
||||
name: '钾素关键',
|
||||
condition: '全钾 < 18 g/kg',
|
||||
severity: 'high',
|
||||
suggestion: '马铃薯需钾量大,影响品质',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'peanut',
|
||||
cropName: '花生',
|
||||
category: '经济作物',
|
||||
description: '重要油料作物,喜温暖,耐旱耐瘠',
|
||||
|
||||
soilRequirements: {
|
||||
ph: {
|
||||
optimal: [6.0, 7.0],
|
||||
acceptable: [5.5, 7.5],
|
||||
},
|
||||
organicMatter: {
|
||||
optimal: [18, 30],
|
||||
acceptable: [15, 35],
|
||||
},
|
||||
soilDepth: {
|
||||
optimal: [50, 80],
|
||||
acceptable: [40, 90],
|
||||
},
|
||||
nitrogen: {
|
||||
optimal: [0.8, 1.5],
|
||||
acceptable: [0.6, 2.0],
|
||||
},
|
||||
phosphorus: {
|
||||
optimal: [0.7, 1.4],
|
||||
acceptable: [0.5, 1.8],
|
||||
},
|
||||
potassium: {
|
||||
optimal: [16, 24],
|
||||
acceptable: [14, 28],
|
||||
},
|
||||
drainage: {
|
||||
optimal: [80, 95],
|
||||
acceptable: [70, 100],
|
||||
},
|
||||
},
|
||||
|
||||
climateRequirements: {
|
||||
temperature: {
|
||||
optimal: [22, 30],
|
||||
acceptable: [18, 34],
|
||||
},
|
||||
rainfall: {
|
||||
optimal: [500, 700],
|
||||
acceptable: [400, 900],
|
||||
},
|
||||
sunlight: {
|
||||
optimal: [7, 10],
|
||||
acceptable: [6, 12],
|
||||
},
|
||||
},
|
||||
|
||||
expectedYield: {
|
||||
high: [350, 450],
|
||||
medium: [280, 350],
|
||||
low: [200, 280],
|
||||
},
|
||||
|
||||
growthCycle: {
|
||||
days: 120,
|
||||
seasons: ['春季', '夏季'],
|
||||
},
|
||||
|
||||
riskFactors: [
|
||||
{
|
||||
id: 'waterlogging',
|
||||
name: '涝害风险',
|
||||
condition: '排水性 < 75分',
|
||||
severity: 'high',
|
||||
suggestion: '花生最忌涝,需沙质土壤',
|
||||
},
|
||||
{
|
||||
id: 'calcium_deficiency',
|
||||
name: '钙素缺乏',
|
||||
condition: 'pH < 5.8',
|
||||
severity: 'medium',
|
||||
suggestion: '易出现空壳,施用石灰补钙',
|
||||
},
|
||||
{
|
||||
id: 'late_rain',
|
||||
name: '后期多雨',
|
||||
condition: '秋季降雨 > 250mm',
|
||||
severity: 'medium',
|
||||
suggestion: '影响收获和品质',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据地块环境参数匹配推荐作物
|
||||
*/
|
||||
export function matchCropsForField(fieldFactors: {
|
||||
ph: number;
|
||||
organic: number;
|
||||
depth: number;
|
||||
nitrogen: number;
|
||||
phosphorus: number;
|
||||
potassium: number;
|
||||
drainage: number;
|
||||
temperature?: number;
|
||||
rainfall?: number;
|
||||
}): Array<{
|
||||
crop: CropKnowledge;
|
||||
matchScore: number;
|
||||
suitabilityLevel: '高度推荐' | '推荐' | '谨慎种植' | '不推荐';
|
||||
matchDetails: {
|
||||
factor: string;
|
||||
status: 'optimal' | 'acceptable' | 'unacceptable';
|
||||
value: number;
|
||||
range: string;
|
||||
}[];
|
||||
applicableRisks: typeof cropKnowledgeBase[0]['riskFactors'];
|
||||
expectedYield: [number, number];
|
||||
}> {
|
||||
return cropKnowledgeBase.map(crop => {
|
||||
const matchDetails: any[] = [];
|
||||
let optimalMatches = 0;
|
||||
let acceptableMatches = 0;
|
||||
let totalFactors = 0;
|
||||
|
||||
// 检查土壤因子匹配度
|
||||
const soilFactors = {
|
||||
pH值: { value: fieldFactors.ph, req: crop.soilRequirements.ph },
|
||||
有机质: { value: fieldFactors.organic, req: crop.soilRequirements.organicMatter },
|
||||
土层厚度: { value: fieldFactors.depth, req: crop.soilRequirements.soilDepth },
|
||||
全氮: { value: fieldFactors.nitrogen, req: crop.soilRequirements.nitrogen },
|
||||
全磷: { value: fieldFactors.phosphorus, req: crop.soilRequirements.phosphorus },
|
||||
全钾: { value: fieldFactors.potassium, req: crop.soilRequirements.potassium },
|
||||
排水性: { value: fieldFactors.drainage, req: crop.soilRequirements.drainage },
|
||||
};
|
||||
|
||||
Object.entries(soilFactors).forEach(([name, data]) => {
|
||||
totalFactors++;
|
||||
let status: 'optimal' | 'acceptable' | 'unacceptable';
|
||||
|
||||
if (data.value >= data.req.optimal[0] && data.value <= data.req.optimal[1]) {
|
||||
status = 'optimal';
|
||||
optimalMatches++;
|
||||
} else if (data.value >= data.req.acceptable[0] && data.value <= data.req.acceptable[1]) {
|
||||
status = 'acceptable';
|
||||
acceptableMatches++;
|
||||
} else {
|
||||
status = 'unacceptable';
|
||||
}
|
||||
|
||||
matchDetails.push({
|
||||
factor: name,
|
||||
status,
|
||||
value: data.value,
|
||||
range: `最佳${data.req.optimal[0]}-${data.req.optimal[1]}`,
|
||||
});
|
||||
});
|
||||
|
||||
// 计算匹配分数 (0-100)
|
||||
const matchScore = Math.round(
|
||||
(optimalMatches * 100 + acceptableMatches * 60) / totalFactors
|
||||
);
|
||||
|
||||
// 确定适宜性等级
|
||||
let suitabilityLevel: '高度推荐' | '推荐' | '谨慎种植' | '不推荐';
|
||||
if (matchScore >= 85) {
|
||||
suitabilityLevel = '高度推荐';
|
||||
} else if (matchScore >= 70) {
|
||||
suitabilityLevel = '推荐';
|
||||
} else if (matchScore >= 50) {
|
||||
suitabilityLevel = '谨慎种植';
|
||||
} else {
|
||||
suitabilityLevel = '不推荐';
|
||||
}
|
||||
|
||||
// 筛选适用的风险因子
|
||||
const applicableRisks = crop.riskFactors.filter(risk => {
|
||||
// 根据实际值判断是否触发风险
|
||||
if (risk.id === 'ph_low' && fieldFactors.ph < 5.5) return true;
|
||||
if (risk.id === 'ph_high' && fieldFactors.ph > 7.8) return true;
|
||||
if (risk.id === 'nitrogen_low' && fieldFactors.nitrogen < 1.2) return true;
|
||||
if (risk.id === 'phosphorus_low' && fieldFactors.phosphorus < 0.6) return true;
|
||||
if (risk.id === 'phosphorus_critical' && fieldFactors.phosphorus < 0.7) return true;
|
||||
if (risk.id === 'potassium_low' && fieldFactors.potassium < 16) return true;
|
||||
if (risk.id === 'potassium_high' && fieldFactors.potassium < 18) return true;
|
||||
if (risk.id === 'potassium_critical' && fieldFactors.potassium < 18) return true;
|
||||
if (risk.id === 'organic_low' && fieldFactors.organic < 16) return true;
|
||||
if (risk.id === 'waterlogging' && fieldFactors.drainage < 60) return true;
|
||||
if (risk.id === 'nitrogen_excess' && fieldFactors.nitrogen > 2.8) return true;
|
||||
if (risk.id === 'calcium_deficiency' && fieldFactors.ph < 5.8) return true;
|
||||
|
||||
// 气候相关风险(使用默认值或传入值)
|
||||
const temp = fieldFactors.temperature || 22;
|
||||
const rainfall = fieldFactors.rainfall || 800;
|
||||
|
||||
if (risk.id === 'drought' && rainfall < 900) return true;
|
||||
if (risk.id === 'drought_spring' && rainfall < 100) return true;
|
||||
if (risk.id === 'drought_pod' && rainfall < 200) return true;
|
||||
if (risk.id === 'temperature_low' && temp < 18) return true;
|
||||
if (risk.id === 'temperature_high' && temp > 24) return true;
|
||||
if (risk.id === 'high_temp' && temp > 32) return true;
|
||||
if (risk.id === 'spring_cold' && temp < 8) return true;
|
||||
if (risk.id === 'late_rain' && rainfall > 300) return true;
|
||||
if (risk.id === 'humidity_high' && fieldFactors.drainage < 70) return true;
|
||||
if (risk.id === 'temperature_extreme' && (temp < 15 || temp > 30)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// 根据适宜性等级确定预期产量范围
|
||||
let expectedYield: [number, number];
|
||||
if (suitabilityLevel === '高度推荐') {
|
||||
expectedYield = crop.expectedYield.high;
|
||||
} else if (suitabilityLevel === '推荐') {
|
||||
expectedYield = crop.expectedYield.medium;
|
||||
} else {
|
||||
expectedYield = crop.expectedYield.low;
|
||||
}
|
||||
|
||||
return {
|
||||
crop,
|
||||
matchScore,
|
||||
suitabilityLevel,
|
||||
matchDetails,
|
||||
applicableRisks,
|
||||
expectedYield,
|
||||
};
|
||||
}).sort((a, b) => b.matchScore - a.matchScore); // 按匹配分数降序排列
|
||||
}
|
||||
666
src/lib/faultCodeLibrary.ts
Normal file
666
src/lib/faultCodeLibrary.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* 故障码库 - 基于农机厂商提供的标准故障码
|
||||
* 包含诊断规则、预测模型和解决方案知识库
|
||||
*/
|
||||
|
||||
// 故障码定义
|
||||
export interface FaultCode {
|
||||
code: string;
|
||||
name: string;
|
||||
category: FaultCategory;
|
||||
level: 'info' | 'warning' | 'error' | 'critical';
|
||||
description: string;
|
||||
|
||||
// 诊断规则
|
||||
diagnosticRules: DiagnosticRule[];
|
||||
|
||||
// 预测条件
|
||||
predictionConditions?: PredictionCondition[];
|
||||
|
||||
// 解决方案
|
||||
solutions: Solution[];
|
||||
|
||||
// 关联知识库
|
||||
knowledgeBaseIds: string[];
|
||||
|
||||
// 厂商信息
|
||||
manufacturer?: string;
|
||||
applicableModels?: string[];
|
||||
}
|
||||
|
||||
export type FaultCategory =
|
||||
| '发动机系统'
|
||||
| '传动系统'
|
||||
| '液压系统'
|
||||
| '电气系统'
|
||||
| '冷却系统'
|
||||
| '燃油系统'
|
||||
| '排放系统'
|
||||
| '作业装置'
|
||||
| '其他';
|
||||
|
||||
// 诊断规则
|
||||
export interface DiagnosticRule {
|
||||
condition: string; // 触发条件描述
|
||||
sensorKeys: string[]; // 关联的传感器参数
|
||||
threshold?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
duration?: number; // 持续时间(秒)
|
||||
};
|
||||
diagnosis: string; // 诊断结果
|
||||
confidence: number; // 置信度 0-1
|
||||
}
|
||||
|
||||
// 预测条件(用于潜在故障预测)
|
||||
export interface PredictionCondition {
|
||||
type: 'usage' | 'sensor' | 'pattern'; // 预测类型
|
||||
description: string;
|
||||
indicators: {
|
||||
key: string; // 指标键值
|
||||
threshold: number;
|
||||
unit?: string;
|
||||
}[];
|
||||
warningMessage: string;
|
||||
recommendedAction: string;
|
||||
}
|
||||
|
||||
// 解决方案
|
||||
export interface Solution {
|
||||
step: number;
|
||||
action: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
estimatedTime?: string; // 预计耗时
|
||||
requiredTools?: string[]; // 所需工具
|
||||
requiredParts?: string[]; // 所需零件
|
||||
safetyWarning?: string; // 安全提示
|
||||
}
|
||||
|
||||
/**
|
||||
* 故障码库数据
|
||||
*/
|
||||
export const FAULT_CODE_LIBRARY: FaultCode[] = [
|
||||
// ========== 发动机系统 ==========
|
||||
{
|
||||
code: 'P0301',
|
||||
name: '第1缸失火',
|
||||
category: '发动机系统',
|
||||
level: 'error',
|
||||
description: '发动机第1缸检测到失火现象',
|
||||
diagnosticRules: [
|
||||
{
|
||||
condition: '第1缸点火信号异常',
|
||||
sensorKeys: ['cylinder1_misfire_count'],
|
||||
threshold: { min: 3, duration: 10 },
|
||||
diagnosis: '火花塞老化或点火线圈故障',
|
||||
confidence: 0.85,
|
||||
},
|
||||
{
|
||||
condition: '燃油喷射异常',
|
||||
sensorKeys: ['fuel_pressure', 'injector1_status'],
|
||||
diagnosis: '喷油嘴堵塞或燃油压力不足',
|
||||
confidence: 0.75,
|
||||
},
|
||||
],
|
||||
predictionConditions: [
|
||||
{
|
||||
type: 'usage',
|
||||
description: '火花塞使用寿命预警',
|
||||
indicators: [
|
||||
{ key: 'spark_plug_1_hours', threshold: 500, unit: '小时' },
|
||||
],
|
||||
warningMessage: '火花塞使用寿命已达90%,建议更换',
|
||||
recommendedAction: '计划在下次保养时更换火花塞',
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
step: 1,
|
||||
action: '检查第1缸火花塞状态,观察是否有烧蚀、积碳',
|
||||
priority: 'high',
|
||||
estimatedTime: '10分钟',
|
||||
requiredTools: ['火花塞扳手', '间隙规'],
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: '检查点火线圈输出,测量电阻值',
|
||||
priority: 'high',
|
||||
estimatedTime: '15分钟',
|
||||
requiredTools: ['万用表'],
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: '检查燃油喷射系统,清洗或更换喷油嘴',
|
||||
priority: 'medium',
|
||||
estimatedTime: '30分钟',
|
||||
requiredTools: ['喷油嘴清洗设备'],
|
||||
requiredParts: ['喷油嘴(如需更换)'],
|
||||
},
|
||||
],
|
||||
knowledgeBaseIds: ['KB001', 'KB015'],
|
||||
},
|
||||
|
||||
{
|
||||
code: 'P0171',
|
||||
name: '系统过稀',
|
||||
category: '燃油系统',
|
||||
level: 'warning',
|
||||
description: '燃油系统混合气过稀',
|
||||
diagnosticRules: [
|
||||
{
|
||||
condition: '空燃比传感器检测混合气过稀',
|
||||
sensorKeys: ['o2_sensor_voltage', 'fuel_trim'],
|
||||
diagnosis: '空气滤清器堵塞或进气系统漏气',
|
||||
confidence: 0.80,
|
||||
},
|
||||
{
|
||||
condition: '燃油压力低于标准值',
|
||||
sensorKeys: ['fuel_pressure'],
|
||||
threshold: { max: 250 }, // kPa
|
||||
diagnosis: '燃油泵压力不足或燃油滤清器堵塞',
|
||||
confidence: 0.85,
|
||||
},
|
||||
],
|
||||
predictionConditions: [
|
||||
{
|
||||
type: 'usage',
|
||||
description: '空气滤清器使用时间预警',
|
||||
indicators: [
|
||||
{ key: 'air_filter_hours', threshold: 500, unit: '小时' },
|
||||
],
|
||||
warningMessage: '空气滤清器使用超过500小时,可能影响进气效率',
|
||||
recommendedAction: '检查并更换空气滤清器',
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
step: 1,
|
||||
action: '检查并更换空气滤清器',
|
||||
priority: 'high',
|
||||
estimatedTime: '10分钟',
|
||||
requiredParts: ['空气滤清器'],
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: '检查燃油泵工作压力',
|
||||
priority: 'high',
|
||||
estimatedTime: '20分钟',
|
||||
requiredTools: ['燃油压力表'],
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: '检查进气系统是否有漏气',
|
||||
priority: 'medium',
|
||||
estimatedTime: '30分钟',
|
||||
requiredTools: ['烟雾机'],
|
||||
},
|
||||
],
|
||||
knowledgeBaseIds: ['KB002', 'KB018'],
|
||||
},
|
||||
|
||||
{
|
||||
code: 'C0040',
|
||||
name: '发动机温度过高',
|
||||
category: '冷却系统',
|
||||
level: 'critical',
|
||||
description: '发动机冷却液温度超过安全阈值',
|
||||
diagnosticRules: [
|
||||
{
|
||||
condition: '冷却液温度超过105℃',
|
||||
sensorKeys: ['coolant_temp'],
|
||||
threshold: { min: 105 },
|
||||
diagnosis: '冷却系统故障,需立即停机',
|
||||
confidence: 0.95,
|
||||
},
|
||||
{
|
||||
condition: '温度持续上升且风扇未启动',
|
||||
sensorKeys: ['coolant_temp', 'fan_status'],
|
||||
diagnosis: '散热风扇故障',
|
||||
confidence: 0.90,
|
||||
},
|
||||
],
|
||||
predictionConditions: [
|
||||
{
|
||||
type: 'sensor',
|
||||
description: '冷却液温度趋势预警',
|
||||
indicators: [
|
||||
{ key: 'coolant_temp_trend', threshold: 2, unit: '℃/分钟' },
|
||||
],
|
||||
warningMessage: '冷却液温度上升过快,可能即将过热',
|
||||
recommendedAction: '减轻负荷,检查冷却系统',
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
step: 1,
|
||||
action: '立即停机,等待冷却',
|
||||
priority: 'high',
|
||||
estimatedTime: '30分钟',
|
||||
safetyWarning: '⚠️ 严禁在高温时打开水箱盖,避免烫伤!',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: '检查冷却液液位,必要时补充',
|
||||
priority: 'high',
|
||||
estimatedTime: '10分钟',
|
||||
requiredParts: ['冷却液'],
|
||||
safetyWarning: '确保发动机已完全冷却',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: '检查水泵工作状态',
|
||||
priority: 'high',
|
||||
estimatedTime: '20分钟',
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: '检查散热器是否堵塞',
|
||||
priority: 'medium',
|
||||
estimatedTime: '15分钟',
|
||||
},
|
||||
],
|
||||
knowledgeBaseIds: ['KB003', 'KB025'],
|
||||
},
|
||||
|
||||
// ========== 预测性故障 ==========
|
||||
{
|
||||
code: 'W1001',
|
||||
name: '发动机积碳预警',
|
||||
category: '发动机系统',
|
||||
level: 'warning',
|
||||
description: '根据运行模式和燃油消耗分析,预测发动机可能存在积碳',
|
||||
diagnosticRules: [
|
||||
{
|
||||
condition: '长时间低转速运行',
|
||||
sensorKeys: ['engine_rpm', 'low_rpm_duration'],
|
||||
threshold: { max: 1200, duration: 7200 }, // 低于1200转持续2小时
|
||||
diagnosis: '长期低转速运行容易产生积碳',
|
||||
confidence: 0.70,
|
||||
},
|
||||
{
|
||||
condition: '燃油消耗率异常升高',
|
||||
sensorKeys: ['fuel_consumption_rate'],
|
||||
diagnosis: '燃烧效率下降,可能积碳严重',
|
||||
confidence: 0.75,
|
||||
},
|
||||
],
|
||||
predictionConditions: [
|
||||
{
|
||||
type: 'pattern',
|
||||
description: '发动机积碳风险评估',
|
||||
indicators: [
|
||||
{ key: 'low_rpm_hours_ratio', threshold: 0.6, unit: '占比' },
|
||||
{ key: 'fuel_efficiency_drop', threshold: 15, unit: '%' },
|
||||
],
|
||||
warningMessage: '发动机存在积碳风险,建议进行清洗保养',
|
||||
recommendedAction: '安排发动机清洗维护,使用积碳清洗剂',
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
step: 1,
|
||||
action: '使用发动机积碳清洗剂',
|
||||
priority: 'medium',
|
||||
estimatedTime: '1小时',
|
||||
requiredParts: ['积碳清洗剂'],
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: '适当提高发动机转速运行30分钟',
|
||||
priority: 'medium',
|
||||
estimatedTime: '30分钟',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: '检查火花塞积碳情况,必要时更换',
|
||||
priority: 'low',
|
||||
estimatedTime: '20分钟',
|
||||
},
|
||||
],
|
||||
knowledgeBaseIds: ['KB030', 'KB031'],
|
||||
},
|
||||
|
||||
{
|
||||
code: 'W1002',
|
||||
name: '皮带磨损预警',
|
||||
category: '传动系统',
|
||||
level: 'warning',
|
||||
description: '根据使用时间和振动数据,预测传动皮带可能需要更换',
|
||||
diagnosticRules: [
|
||||
{
|
||||
condition: '皮带使用时间接近更换周期',
|
||||
sensorKeys: ['belt_usage_hours'],
|
||||
threshold: { min: 1800 }, // 1800小时
|
||||
diagnosis: '皮带接近使用寿命',
|
||||
confidence: 0.80,
|
||||
},
|
||||
{
|
||||
condition: '振动传感器检测异常',
|
||||
sensorKeys: ['vibration_level'],
|
||||
threshold: { min: 0.5 },
|
||||
diagnosis: '皮带松弛或磨损导致振动增大',
|
||||
confidence: 0.75,
|
||||
},
|
||||
],
|
||||
predictionConditions: [
|
||||
{
|
||||
type: 'usage',
|
||||
description: '皮带寿命预测',
|
||||
indicators: [
|
||||
{ key: 'belt_usage_hours', threshold: 1800, unit: '小时' },
|
||||
{ key: 'belt_tension_drop', threshold: 20, unit: '%' },
|
||||
],
|
||||
warningMessage: '皮带磨损严重,建议及时更换避免断裂',
|
||||
recommendedAction: '准备备用皮带,计划更换时间',
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
step: 1,
|
||||
action: '检查皮带张紧度',
|
||||
priority: 'high',
|
||||
estimatedTime: '10分钟',
|
||||
requiredTools: ['张紧度测量仪'],
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: '检查皮带表面磨损、裂纹',
|
||||
priority: 'high',
|
||||
estimatedTime: '10分钟',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: '更换传动皮带',
|
||||
priority: 'high',
|
||||
estimatedTime: '45分钟',
|
||||
requiredParts: ['传动皮带'],
|
||||
requiredTools: ['扳手组', '张紧工具'],
|
||||
},
|
||||
],
|
||||
knowledgeBaseIds: ['KB040', 'KB041'],
|
||||
},
|
||||
|
||||
{
|
||||
code: 'W1003',
|
||||
name: '液压油变质预警',
|
||||
category: '液压系统',
|
||||
level: 'warning',
|
||||
description: '根据液压油使用时间和温度数据,预测液压油可能需要更换',
|
||||
diagnosticRules: [
|
||||
{
|
||||
condition: '液压油使用时间超过标准',
|
||||
sensorKeys: ['hydraulic_oil_hours'],
|
||||
threshold: { min: 500 },
|
||||
diagnosis: '液压油接近更换周期',
|
||||
confidence: 0.85,
|
||||
},
|
||||
{
|
||||
condition: '液压油温度频繁过高',
|
||||
sensorKeys: ['hydraulic_oil_temp', 'over_temp_count'],
|
||||
threshold: { min: 80, duration: 3600 },
|
||||
diagnosis: '高温加速液压油老化',
|
||||
confidence: 0.80,
|
||||
},
|
||||
],
|
||||
predictionConditions: [
|
||||
{
|
||||
type: 'sensor',
|
||||
description: '液压油质量评估',
|
||||
indicators: [
|
||||
{ key: 'hydraulic_oil_hours', threshold: 500, unit: '小时' },
|
||||
{ key: 'over_temp_hours', threshold: 50, unit: '小时' },
|
||||
],
|
||||
warningMessage: '液压油性能下降,建议更换以保护液压系统',
|
||||
recommendedAction: '安排液压油更换和系统清洗',
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
step: 1,
|
||||
action: '抽取液压油样本,检查颜色和杂质',
|
||||
priority: 'medium',
|
||||
estimatedTime: '10分钟',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: '更换液压油和滤芯',
|
||||
priority: 'high',
|
||||
estimatedTime: '2小时',
|
||||
requiredParts: ['液压油', '液压滤芯'],
|
||||
requiredTools: ['油桶', '扳手'],
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: '清洗液压油箱',
|
||||
priority: 'medium',
|
||||
estimatedTime: '1小时',
|
||||
},
|
||||
],
|
||||
knowledgeBaseIds: ['KB050', 'KB051'],
|
||||
},
|
||||
|
||||
// ========== 电气系统 ==========
|
||||
{
|
||||
code: 'U0100',
|
||||
name: 'CAN通信故障',
|
||||
category: '电气系统',
|
||||
level: 'error',
|
||||
description: 'CAN总线通信中断或数据异常',
|
||||
diagnosticRules: [
|
||||
{
|
||||
condition: 'CAN总线无数据',
|
||||
sensorKeys: ['can_bus_status'],
|
||||
diagnosis: '总线连接故障或控制器故障',
|
||||
confidence: 0.90,
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
step: 1,
|
||||
action: '检查CAN总线插头连接',
|
||||
priority: 'high',
|
||||
estimatedTime: '15分钟',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: '使用诊断仪检测总线电压',
|
||||
priority: 'high',
|
||||
estimatedTime: '20分钟',
|
||||
requiredTools: ['CAN总线诊断仪'],
|
||||
},
|
||||
],
|
||||
knowledgeBaseIds: ['KB060'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 解决方案知识库
|
||||
*/
|
||||
export interface KnowledgeBaseArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
content: string;
|
||||
diagrams?: string[]; // 图片URL
|
||||
videos?: string[]; // 视频URL
|
||||
relatedFaultCodes: string[];
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
estimatedTime: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
viewCount: number;
|
||||
helpfulCount: number;
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_BASE: KnowledgeBaseArticle[] = [
|
||||
{
|
||||
id: 'KB001',
|
||||
title: '火花塞检查与更换指南',
|
||||
category: '发动机维修',
|
||||
tags: ['火花塞', '发动机', '失火', '点火系统'],
|
||||
content: `
|
||||
# 火花塞检查与更换指南
|
||||
|
||||
## 检查步骤
|
||||
1. 拆下火花塞
|
||||
2. 观察电极磨损情况
|
||||
3. 检查间隙(标准:0.8-0.9mm)
|
||||
4. 查看颜色(正常为灰白色)
|
||||
|
||||
## 更换标准
|
||||
- 电极磨损严重
|
||||
- 绝缘体开裂
|
||||
- 积碳严重无法清理
|
||||
- 使用超过500小时
|
||||
|
||||
## 注意事项
|
||||
⚠️ 更换时注意扭矩规格
|
||||
⚠️ 使用正确型号的火花塞
|
||||
`,
|
||||
relatedFaultCodes: ['P0301', 'P0302', 'P0303', 'P0304'],
|
||||
difficulty: 'easy',
|
||||
estimatedTime: '30分钟',
|
||||
author: '技术团队',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-10-01',
|
||||
viewCount: 1250,
|
||||
helpfulCount: 980,
|
||||
},
|
||||
{
|
||||
id: 'KB003',
|
||||
title: '发动机过热故障排查流程',
|
||||
category: '冷却系统',
|
||||
tags: ['过热', '冷却系统', '水温', '散热器'],
|
||||
content: `
|
||||
# 发动机过热故障排查流程
|
||||
|
||||
## 紧急处理
|
||||
1. 立即停机,避免拉缸
|
||||
2. 等待完全冷却(至少30分钟)
|
||||
3. 检查冷却液液位
|
||||
|
||||
## 常见原因
|
||||
- 冷却液不足
|
||||
- 水泵故障
|
||||
- 节温器卡死
|
||||
- 散热器堵塞
|
||||
- 风扇不转
|
||||
|
||||
## 排查顺序
|
||||
1. 检查液位(最简单)
|
||||
2. 检查风扇(观察即可)
|
||||
3. 检查水泵皮带
|
||||
4. 检查散热器
|
||||
5. 检查节温器
|
||||
|
||||
## 预防措施
|
||||
✅ 定期检查冷却液
|
||||
✅ 清洗散热器
|
||||
✅ 检查风扇工作
|
||||
`,
|
||||
relatedFaultCodes: ['C0040'],
|
||||
difficulty: 'medium',
|
||||
estimatedTime: '1-2小时',
|
||||
author: '技术团队',
|
||||
createdAt: '2024-01-15',
|
||||
updatedAt: '2024-09-20',
|
||||
viewCount: 2100,
|
||||
helpfulCount: 1850,
|
||||
},
|
||||
{
|
||||
id: 'KB030',
|
||||
title: '发动机积碳清理与预防',
|
||||
category: '发动机维护',
|
||||
tags: ['积碳', '清洗', '保养', '燃烧室'],
|
||||
content: `
|
||||
# 发动机积碳清理与预<E4B88E><E9A284><EFBFBD>
|
||||
|
||||
## 积碳产生原因
|
||||
- 长期低速运行
|
||||
- 燃油质量差
|
||||
- 进气系统污染
|
||||
- 火花塞老化
|
||||
|
||||
## 清理方法
|
||||
### 1. 化学清洗
|
||||
使用专用积碳清洗剂,从进气道注入
|
||||
|
||||
### 2. 拆解清洗
|
||||
拆卸火花塞,刮除积碳
|
||||
|
||||
### 3. 核桃砂清洗
|
||||
专业设备清洗进气道
|
||||
|
||||
## 预防措施
|
||||
✅ 定期高转速运行
|
||||
✅ 使用优质燃油
|
||||
✅ 定期更换空滤
|
||||
✅ 添加燃油添加剂
|
||||
`,
|
||||
relatedFaultCodes: ['W1001'],
|
||||
difficulty: 'medium',
|
||||
estimatedTime: '2-3小时',
|
||||
author: '资深技师',
|
||||
createdAt: '2024-03-10',
|
||||
updatedAt: '2024-10-05',
|
||||
viewCount: 1580,
|
||||
helpfulCount: 1320,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据故障码查找
|
||||
*/
|
||||
export function getFaultCodeInfo(code: string): FaultCode | undefined {
|
||||
return FAULT_CODE_LIBRARY.find(f => f.code === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类查找故障码
|
||||
*/
|
||||
export function getFaultCodesByCategory(category: FaultCategory): FaultCode[] {
|
||||
return FAULT_CODE_LIBRARY.filter(f => f.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据故障码查找知识库文章
|
||||
*/
|
||||
export function getKnowledgeBaseArticles(faultCode: string): KnowledgeBaseArticle[] {
|
||||
return KNOWLEDGE_BASE.filter(kb => kb.relatedFaultCodes.includes(faultCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析传感器数据,预测潜在故障
|
||||
*/
|
||||
export function predictPotentialFaults(sensorData: Record<string, any>): {
|
||||
faultCode: string;
|
||||
confidence: number;
|
||||
message: string;
|
||||
action: string;
|
||||
}[] {
|
||||
const predictions: any[] = [];
|
||||
|
||||
FAULT_CODE_LIBRARY.forEach(fault => {
|
||||
if (!fault.predictionConditions) return;
|
||||
|
||||
fault.predictionConditions.forEach(condition => {
|
||||
const allIndicatorsMet = condition.indicators.every(indicator => {
|
||||
const value = sensorData[indicator.key];
|
||||
return value !== undefined && value >= indicator.threshold;
|
||||
});
|
||||
|
||||
if (allIndicatorsMet) {
|
||||
predictions.push({
|
||||
faultCode: fault.code,
|
||||
confidence: 0.75,
|
||||
message: condition.warningMessage,
|
||||
action: condition.recommendedAction,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return predictions;
|
||||
}
|
||||
232
src/lib/fieldVersionManager.ts
Normal file
232
src/lib/fieldVersionManager.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Field, FieldVersion, FieldVersionChange } from '../types/field';
|
||||
|
||||
/**
|
||||
* 地块版本管理工具
|
||||
*/
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
// 比较两个值是否不同
|
||||
const isDifferent = (oldValue: any, newValue: any): boolean => {
|
||||
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
|
||||
return JSON.stringify(oldValue) !== JSON.stringify(newValue);
|
||||
}
|
||||
return oldValue !== newValue;
|
||||
};
|
||||
|
||||
// 检测字段变更
|
||||
const detectChanges = (oldField: Partial<Field>, newField: Partial<Field>): FieldVersionChange[] => {
|
||||
const changes: FieldVersionChange[] = [];
|
||||
|
||||
// 需要监控的字段
|
||||
const monitoredFields = [
|
||||
'name',
|
||||
'code',
|
||||
'area',
|
||||
'perimeter',
|
||||
'location',
|
||||
'soilType',
|
||||
'landUseType',
|
||||
'plantingMode',
|
||||
'owner',
|
||||
'ownerPhone',
|
||||
'contractPeriod',
|
||||
'certificateNumber',
|
||||
'tags',
|
||||
'status',
|
||||
'elevation',
|
||||
'slope',
|
||||
'aspect',
|
||||
'waterSource',
|
||||
'remarks',
|
||||
];
|
||||
|
||||
for (const field of monitoredFields) {
|
||||
const oldValue = (oldField as any)[field];
|
||||
const newValue = (newField as any)[field];
|
||||
|
||||
if (isDifferent(oldValue, newValue)) {
|
||||
changes.push({
|
||||
field,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
// 检测边界变更
|
||||
const isBoundaryChanged = (oldCoordinates: any[], newCoordinates: any[]): boolean => {
|
||||
if (!oldCoordinates || !newCoordinates) return false;
|
||||
if (oldCoordinates.length !== newCoordinates.length) return true;
|
||||
return JSON.stringify(oldCoordinates) !== JSON.stringify(newCoordinates);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建版本记录
|
||||
*/
|
||||
export const createFieldVersion = (
|
||||
oldField: Field | null,
|
||||
newField: Field,
|
||||
changeType: FieldVersion['changeType'],
|
||||
changedBy: string,
|
||||
remarks?: string
|
||||
): FieldVersion | null => {
|
||||
// 如果是新建,创建初始版本
|
||||
if (changeType === 'create') {
|
||||
return {
|
||||
id: generateId(),
|
||||
fieldId: newField.id,
|
||||
version: 1,
|
||||
changeType: 'create',
|
||||
changes: [],
|
||||
coordinates: newField.coordinates,
|
||||
attributes: { ...newField },
|
||||
changedBy,
|
||||
changedAt: new Date().toISOString(),
|
||||
remarks: remarks || '创建地块',
|
||||
};
|
||||
}
|
||||
|
||||
// 如果没有旧数据,无法比较
|
||||
if (!oldField) return null;
|
||||
|
||||
// 检测变更
|
||||
const attributeChanges = detectChanges(oldField, newField);
|
||||
const boundaryChanged = isBoundaryChanged(oldField.coordinates, newField.coordinates);
|
||||
|
||||
// 如果有边界变更,添加到变更列表
|
||||
if (boundaryChanged) {
|
||||
attributeChanges.push({
|
||||
field: 'coordinates',
|
||||
oldValue: oldField.coordinates.length,
|
||||
newValue: newField.coordinates.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有任何变更,不创建版本
|
||||
if (attributeChanges.length === 0 && !boundaryChanged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 确定变更类型
|
||||
let finalChangeType = changeType;
|
||||
if (changeType === 'update-boundary' || changeType === 'update-attributes') {
|
||||
// 自动检测
|
||||
if (boundaryChanged && attributeChanges.length > 1) {
|
||||
finalChangeType = 'update-boundary'; // 边界变更优先
|
||||
} else if (boundaryChanged) {
|
||||
finalChangeType = 'update-boundary';
|
||||
} else {
|
||||
finalChangeType = 'update-attributes';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
fieldId: newField.id,
|
||||
version: (oldField.currentVersion || 0) + 1,
|
||||
changeType: finalChangeType,
|
||||
changes: attributeChanges,
|
||||
coordinates: boundaryChanged ? newField.coordinates : undefined,
|
||||
attributes: { ...newField },
|
||||
changedBy,
|
||||
changedAt: new Date().toISOString(),
|
||||
remarks,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存版本到localStorage
|
||||
*/
|
||||
export const saveFieldVersion = (version: FieldVersion): void => {
|
||||
try {
|
||||
const key = `field_versions_${version.fieldId}`;
|
||||
const data = localStorage.getItem(key);
|
||||
let versions: FieldVersion[] = data ? JSON.parse(data) : [];
|
||||
|
||||
// 添加新版本
|
||||
versions.push(version);
|
||||
|
||||
// 保存
|
||||
localStorage.setItem(key, JSON.stringify(versions));
|
||||
} catch (error) {
|
||||
console.error('保存版本失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载地块的所有版本
|
||||
*/
|
||||
export const loadFieldVersions = (fieldId: string): FieldVersion[] => {
|
||||
try {
|
||||
const key = `field_versions_${fieldId}`;
|
||||
const data = localStorage.getItem(key);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (error) {
|
||||
console.error('加载版本失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取特定版本
|
||||
*/
|
||||
export const getFieldVersion = (fieldId: string, version: number): FieldVersion | null => {
|
||||
const versions = loadFieldVersions(fieldId);
|
||||
return versions.find(v => v.version === version) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从版本恢复地块数据
|
||||
*/
|
||||
export const restoreFromVersion = (version: FieldVersion): Partial<Field> => {
|
||||
if (!version.attributes) {
|
||||
throw new Error('版本数据不完整');
|
||||
}
|
||||
|
||||
const restored: Partial<Field> = { ...version.attributes };
|
||||
|
||||
// 如果有边界数据,使用版本的边界
|
||||
if (version.coordinates) {
|
||||
restored.coordinates = version.coordinates;
|
||||
}
|
||||
|
||||
return restored;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除地块的所有版本记录
|
||||
*/
|
||||
export const deleteFieldVersions = (fieldId: string): void => {
|
||||
try {
|
||||
const key = `field_versions_${fieldId}`;
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('删除版本失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取版本统计信息
|
||||
*/
|
||||
export const getVersionStatistics = (fieldId: string) => {
|
||||
const versions = loadFieldVersions(fieldId);
|
||||
|
||||
return {
|
||||
total: versions.length,
|
||||
byType: versions.reduce((acc, v) => {
|
||||
acc[v.changeType] = (acc[v.changeType] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<FieldVersion['changeType'], number>),
|
||||
latestVersion: versions.length > 0
|
||||
? Math.max(...versions.map(v => v.version))
|
||||
: 0,
|
||||
};
|
||||
};
|
||||
307
src/lib/fieldVersionSamples.ts
Normal file
307
src/lib/fieldVersionSamples.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { FieldVersion } from '../types/field';
|
||||
|
||||
/**
|
||||
* 地块版本历史示例数据
|
||||
* 用于演示版本管理功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* 为指定地块生成示例版本历史数据
|
||||
*/
|
||||
export const generateSampleVersions = (fieldId: string, fieldCode: string): FieldVersion[] => {
|
||||
const baseDate = new Date('2024-01-15');
|
||||
|
||||
return [
|
||||
// 版本1:创建地块
|
||||
{
|
||||
id: `${fieldId}-v1`,
|
||||
fieldId,
|
||||
version: 1,
|
||||
changeType: 'create',
|
||||
changes: [],
|
||||
coordinates: [
|
||||
{ lat: 30.5728, lng: 114.2943 },
|
||||
{ lat: 30.5730, lng: 114.2943 },
|
||||
{ lat: 30.5730, lng: 114.2948 },
|
||||
{ lat: 30.5728, lng: 114.2948 },
|
||||
],
|
||||
attributes: {
|
||||
id: fieldId,
|
||||
code: fieldCode,
|
||||
name: '东区1号地块',
|
||||
area: 15.5,
|
||||
perimeter: 520,
|
||||
location: '湖北省武汉市江夏区',
|
||||
soilType: 'loamy',
|
||||
landUseType: 'farmland',
|
||||
plantingMode: 'open-field',
|
||||
owner: '张三',
|
||||
ownerPhone: '13800138001',
|
||||
contractPeriod: 30,
|
||||
tags: ['优质地块'],
|
||||
status: 'normal',
|
||||
},
|
||||
changedBy: '系统管理员',
|
||||
changedAt: baseDate.toISOString(),
|
||||
remarks: '创建地块',
|
||||
},
|
||||
|
||||
// 版本2:更新基本信息
|
||||
{
|
||||
id: `${fieldId}-v2`,
|
||||
fieldId,
|
||||
version: 2,
|
||||
changeType: 'update-attributes',
|
||||
changes: [
|
||||
{
|
||||
field: 'area',
|
||||
oldValue: 15.5,
|
||||
newValue: 16.2,
|
||||
},
|
||||
{
|
||||
field: 'owner',
|
||||
oldValue: '张三',
|
||||
newValue: '张三(已确权)',
|
||||
},
|
||||
{
|
||||
field: 'certificateNumber',
|
||||
oldValue: undefined,
|
||||
newValue: 'JX202401001',
|
||||
},
|
||||
],
|
||||
changedBy: '王芳',
|
||||
changedAt: new Date(baseDate.getTime() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remarks: '完善权属信息,更新实测面积',
|
||||
},
|
||||
|
||||
// 版本3:添加标签
|
||||
{
|
||||
id: `${fieldId}-v3`,
|
||||
fieldId,
|
||||
version: 3,
|
||||
changeType: 'update-attributes',
|
||||
changes: [
|
||||
{
|
||||
field: 'tags',
|
||||
oldValue: ['优质地块'],
|
||||
newValue: ['优质地块', '高产田', '水源充足'],
|
||||
},
|
||||
],
|
||||
changedBy: '李明',
|
||||
changedAt: new Date(baseDate.getTime() + 45 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remarks: '根据实地考察添加标签',
|
||||
},
|
||||
|
||||
// 版本4:调整边界
|
||||
{
|
||||
id: `${fieldId}-v4`,
|
||||
fieldId,
|
||||
version: 4,
|
||||
changeType: 'update-boundary',
|
||||
changes: [
|
||||
{
|
||||
field: 'coordinates',
|
||||
oldValue: 4,
|
||||
newValue: 5,
|
||||
},
|
||||
{
|
||||
field: 'area',
|
||||
oldValue: 16.2,
|
||||
newValue: 16.8,
|
||||
},
|
||||
{
|
||||
field: 'perimeter',
|
||||
oldValue: 520,
|
||||
newValue: 545,
|
||||
},
|
||||
],
|
||||
coordinates: [
|
||||
{ lat: 30.5728, lng: 114.2943 },
|
||||
{ lat: 30.5730, lng: 114.2943 },
|
||||
{ lat: 30.5731, lng: 114.2946 },
|
||||
{ lat: 30.5730, lng: 114.2948 },
|
||||
{ lat: 30.5728, lng: 114.2948 },
|
||||
],
|
||||
changedBy: '测绘组-赵强',
|
||||
changedAt: new Date(baseDate.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remarks: '使用RTK重新测绘,边界更精确',
|
||||
},
|
||||
|
||||
// 版本5:更新土壤信息
|
||||
{
|
||||
id: `${fieldId}-v5`,
|
||||
fieldId,
|
||||
version: 5,
|
||||
changeType: 'update-attributes',
|
||||
changes: [
|
||||
{
|
||||
field: 'soilType',
|
||||
oldValue: 'loamy',
|
||||
newValue: 'clay',
|
||||
},
|
||||
{
|
||||
field: 'elevation',
|
||||
oldValue: undefined,
|
||||
newValue: 45,
|
||||
},
|
||||
{
|
||||
field: 'slope',
|
||||
oldValue: undefined,
|
||||
newValue: 3.5,
|
||||
},
|
||||
],
|
||||
changedBy: '农技员-孙莉',
|
||||
changedAt: new Date(baseDate.getTime() + 120 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remarks: '土壤检测后更新土壤类型,补充地形数据',
|
||||
},
|
||||
|
||||
// 版本6:更改种植模式
|
||||
{
|
||||
id: `${fieldId}-v6`,
|
||||
fieldId,
|
||||
version: 6,
|
||||
changeType: 'update-attributes',
|
||||
changes: [
|
||||
{
|
||||
field: 'plantingMode',
|
||||
oldValue: 'open-field',
|
||||
newValue: 'greenhouse',
|
||||
},
|
||||
{
|
||||
field: 'waterSource',
|
||||
oldValue: undefined,
|
||||
newValue: '水渠灌溉',
|
||||
},
|
||||
],
|
||||
changedBy: '张三',
|
||||
changedAt: new Date(baseDate.getTime() + 180 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remarks: '建设温室大棚,改变种植模式',
|
||||
},
|
||||
|
||||
// 版本7:补充坡向信息
|
||||
{
|
||||
id: `${fieldId}-v7`,
|
||||
fieldId,
|
||||
version: 7,
|
||||
changeType: 'update-attributes',
|
||||
changes: [
|
||||
{
|
||||
field: 'aspect',
|
||||
oldValue: undefined,
|
||||
newValue: '东南',
|
||||
},
|
||||
{
|
||||
field: 'remarks',
|
||||
oldValue: undefined,
|
||||
newValue: '光照条件良好,适合蔬菜种植',
|
||||
},
|
||||
],
|
||||
changedBy: '农技员-孙莉',
|
||||
changedAt: new Date(baseDate.getTime() + 210 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remarks: '完善地块基础信息',
|
||||
},
|
||||
|
||||
// 版本8:更新联系方式
|
||||
{
|
||||
id: `${fieldId}-v8`,
|
||||
fieldId,
|
||||
version: 8,
|
||||
changeType: 'update-attributes',
|
||||
changes: [
|
||||
{
|
||||
field: 'ownerPhone',
|
||||
oldValue: '13800138001',
|
||||
newValue: '13900139001',
|
||||
},
|
||||
],
|
||||
changedBy: '系统管理员',
|
||||
changedAt: new Date(baseDate.getTime() + 250 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
remarks: '应权属人要求更新联系电话',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化示例版本数据到localStorage
|
||||
*/
|
||||
export const initializeSampleVersions = (fieldId: string, fieldCode: string): void => {
|
||||
try {
|
||||
const key = `field_versions_${fieldId}`;
|
||||
|
||||
// 检查是否已有数据
|
||||
const existing = localStorage.getItem(key);
|
||||
if (existing) {
|
||||
console.log(`地块 ${fieldCode} 的版本数据已存在,跳过初始化`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成示例数据
|
||||
const versions = generateSampleVersions(fieldId, fieldCode);
|
||||
|
||||
// 保存到localStorage
|
||||
localStorage.setItem(key, JSON.stringify(versions));
|
||||
|
||||
console.log(`已为地块 ${fieldCode} (ID: ${fieldId}) 生成 ${versions.length} 条版本历史`);
|
||||
} catch (error) {
|
||||
console.error('初始化示例版本数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量初始化多个地块的示例版本
|
||||
*/
|
||||
export const initializeMultipleFieldVersions = (): void => {
|
||||
try {
|
||||
// 获取所有地块
|
||||
const fieldsData = localStorage.getItem('smart_agriculture_fields');
|
||||
if (!fieldsData) {
|
||||
console.log('未找到地块数据,无法初始化版本历史');
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = JSON.parse(fieldsData);
|
||||
|
||||
// 只为前3个地块生成示例数据
|
||||
const fieldsToInit = fields.slice(0, 3);
|
||||
|
||||
fieldsToInit.forEach((field: any) => {
|
||||
initializeSampleVersions(field.id, field.code);
|
||||
});
|
||||
|
||||
console.log(`已为 ${fieldsToInit.length} 个地块初始化版本历史`);
|
||||
} catch (error) {
|
||||
console.error('批量初始化失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除指定地块的示例版本数据
|
||||
*/
|
||||
export const clearSampleVersions = (fieldId: string): void => {
|
||||
try {
|
||||
const key = `field_versions_${fieldId}`;
|
||||
localStorage.removeItem(key);
|
||||
console.log(`已清除地块 ${fieldId} 的版本数据`);
|
||||
} catch (error) {
|
||||
console.error('清除版本数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有地块的版本数据
|
||||
*/
|
||||
export const clearAllVersions = (): void => {
|
||||
try {
|
||||
const fieldsData = localStorage.getItem('smart_agriculture_fields');
|
||||
if (!fieldsData) return;
|
||||
|
||||
const fields = JSON.parse(fieldsData);
|
||||
fields.forEach((field: any) => {
|
||||
clearSampleVersions(field.id);
|
||||
});
|
||||
|
||||
console.log('已清除所有地块的版本数据');
|
||||
} catch (error) {
|
||||
console.error('清除所有版本数据失败:', error);
|
||||
}
|
||||
};
|
||||
526
src/lib/gisMapEngine.ts
Normal file
526
src/lib/gisMapEngine.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* GIS地图引擎 - 统一的地图渲染和管理引擎
|
||||
* 支持多种地图服务商和占位模式
|
||||
*/
|
||||
|
||||
export type MapProvider = 'amap' | 'leaflet' | 'placeholder';
|
||||
export type MapLayer = 'satellite' | 'street' | 'terrain' | 'hybrid';
|
||||
|
||||
export interface MapConfig {
|
||||
provider: MapProvider;
|
||||
container: string | HTMLElement;
|
||||
center?: [number, number]; // [lng, lat]
|
||||
zoom?: number;
|
||||
layer?: MapLayer;
|
||||
features?: MapFeature[];
|
||||
}
|
||||
|
||||
export interface MapFeature {
|
||||
controls?: {
|
||||
zoom?: boolean;
|
||||
scale?: boolean;
|
||||
layers?: boolean;
|
||||
fullscreen?: boolean;
|
||||
measure?: boolean;
|
||||
};
|
||||
interactions?: {
|
||||
drag?: boolean;
|
||||
zoom?: boolean;
|
||||
rotate?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MapPosition {
|
||||
lng: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
export interface MapBounds {
|
||||
northeast: MapPosition;
|
||||
southwest: MapPosition;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
id: string;
|
||||
position: MapPosition;
|
||||
title?: string;
|
||||
content?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface Polygon {
|
||||
id: string;
|
||||
path: MapPosition[];
|
||||
fillColor?: string;
|
||||
strokeColor?: string;
|
||||
fillOpacity?: number;
|
||||
strokeWeight?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GIS地图引擎类
|
||||
*/
|
||||
export class GISMapEngine {
|
||||
private provider: MapProvider;
|
||||
private map: any = null;
|
||||
private markers: Map<string, any> = new Map();
|
||||
private polygons: Map<string, any> = new Map();
|
||||
private currentLayer: MapLayer = 'satellite';
|
||||
private container: HTMLElement | null = null;
|
||||
|
||||
constructor(config: MapConfig) {
|
||||
this.provider = config.provider;
|
||||
this.initialize(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化地图
|
||||
*/
|
||||
private async initialize(config: MapConfig) {
|
||||
const container = typeof config.container === 'string'
|
||||
? document.getElementById(config.container)
|
||||
: config.container;
|
||||
|
||||
if (!container) {
|
||||
console.error('地图容器不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
this.container = container;
|
||||
|
||||
switch (this.provider) {
|
||||
case 'amap':
|
||||
await this.initAMap(config);
|
||||
break;
|
||||
case 'leaflet':
|
||||
await this.initLeaflet(config);
|
||||
break;
|
||||
case 'placeholder':
|
||||
this.initPlaceholder(config);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化高德地图
|
||||
*/
|
||||
private async initAMap(config: MapConfig) {
|
||||
try {
|
||||
// 检查是否已加载高德地图
|
||||
if (!window.AMap) {
|
||||
console.warn('高德地图SDK未加载,切换到占位模式');
|
||||
this.provider = 'placeholder';
|
||||
this.initPlaceholder(config);
|
||||
return;
|
||||
}
|
||||
|
||||
const center = config.center || [116.4074, 39.9042]; // 默认北京
|
||||
const zoom = config.zoom || 13;
|
||||
|
||||
this.map = new window.AMap.Map(this.container, {
|
||||
center: center,
|
||||
zoom: zoom,
|
||||
viewMode: '2D',
|
||||
});
|
||||
|
||||
// 设置图层
|
||||
this.setLayer(config.layer || 'satellite');
|
||||
|
||||
console.log('✅ 高德地图初始化成功');
|
||||
} catch (error) {
|
||||
console.error('高德地图初始化失败:', error);
|
||||
this.provider = 'placeholder';
|
||||
this.initPlaceholder(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Leaflet地图(使用OpenStreetMap)
|
||||
*/
|
||||
private async initLeaflet(config: MapConfig) {
|
||||
try {
|
||||
// 动态加载Leaflet
|
||||
if (!window.L) {
|
||||
await this.loadLeaflet();
|
||||
}
|
||||
|
||||
const center = config.center || [39.9042, 116.4074]; // Leaflet用 [lat, lng]
|
||||
const zoom = config.zoom || 13;
|
||||
|
||||
this.map = window.L.map(this.container).setView([center[1], center[0]], zoom);
|
||||
|
||||
// 设置图层
|
||||
this.setLayer(config.layer || 'street');
|
||||
|
||||
console.log('✅ Leaflet地图初始化成功');
|
||||
} catch (error) {
|
||||
console.error('Leaflet地图初始化失败:', error);
|
||||
this.provider = 'placeholder';
|
||||
this.initPlaceholder(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载Leaflet库
|
||||
*/
|
||||
private async loadLeaflet(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 加载CSS
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// 加载JS
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Leaflet加载失败'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化占位地图
|
||||
*/
|
||||
private initPlaceholder(config: MapConfig) {
|
||||
if (!this.container) return;
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="gis-placeholder-map" style="
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dbeafe 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
">
|
||||
<div style="
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
console.log('✅ 占位地图初始化成功(功能完整)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置地图图层
|
||||
*/
|
||||
setLayer(layer: MapLayer) {
|
||||
this.currentLayer = layer;
|
||||
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
// 高德地图图层
|
||||
this.map.setLayers([this.getAMapLayer(layer)]);
|
||||
} else if (this.provider === 'leaflet' && this.map) {
|
||||
// Leaflet图层
|
||||
this.getLeafletLayer(layer).addTo(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取高德地图图层
|
||||
*/
|
||||
private getAMapLayer(layer: MapLayer) {
|
||||
switch (layer) {
|
||||
case 'satellite':
|
||||
return new window.AMap.TileLayer.Satellite();
|
||||
case 'street':
|
||||
return new window.AMap.TileLayer();
|
||||
case 'terrain':
|
||||
return new window.AMap.TileLayer();
|
||||
case 'hybrid':
|
||||
return [
|
||||
new window.AMap.TileLayer.Satellite(),
|
||||
new window.AMap.TileLayer.RoadNet()
|
||||
];
|
||||
default:
|
||||
return new window.AMap.TileLayer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Leaflet图层
|
||||
*/
|
||||
private getLeafletLayer(layer: MapLayer) {
|
||||
const baseLayers: Record<MapLayer, string> = {
|
||||
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
street: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
terrain: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||
hybrid: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
};
|
||||
|
||||
return window.L.tileLayer(baseLayers[layer], {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加标记点
|
||||
*/
|
||||
addMarker(marker: Marker) {
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
const amapMarker = new window.AMap.Marker({
|
||||
position: [marker.position.lng, marker.position.lat],
|
||||
title: marker.title,
|
||||
});
|
||||
|
||||
if (marker.onClick) {
|
||||
amapMarker.on('click', marker.onClick);
|
||||
}
|
||||
|
||||
this.map.add(amapMarker);
|
||||
this.markers.set(marker.id, amapMarker);
|
||||
} else if (this.provider === 'leaflet' && this.map) {
|
||||
const leafletMarker = window.L.marker([marker.position.lat, marker.position.lng])
|
||||
.addTo(this.map);
|
||||
|
||||
if (marker.title) {
|
||||
leafletMarker.bindPopup(marker.title);
|
||||
}
|
||||
|
||||
if (marker.onClick) {
|
||||
leafletMarker.on('click', marker.onClick);
|
||||
}
|
||||
|
||||
this.markers.set(marker.id, leafletMarker);
|
||||
} else if (this.provider === 'placeholder') {
|
||||
// 占位模式:在容器中添加标记点
|
||||
this.addPlaceholderMarker(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 占位模式添加标记
|
||||
*/
|
||||
private addPlaceholderMarker(marker: Marker) {
|
||||
if (!this.container) return;
|
||||
|
||||
const markerEl = document.createElement('div');
|
||||
markerEl.id = `marker-${marker.id}`;
|
||||
markerEl.style.cssText = `
|
||||
position: absolute;
|
||||
left: ${Math.random() * 80 + 10}%;
|
||||
top: ${Math.random() * 80 + 10}%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: ${marker.color || '#22c55e'};
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
if (marker.onClick) {
|
||||
markerEl.addEventListener('click', marker.onClick);
|
||||
}
|
||||
|
||||
this.container.querySelector('.gis-placeholder-map')?.appendChild(markerEl);
|
||||
this.markers.set(marker.id, markerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加多边形
|
||||
*/
|
||||
addPolygon(polygon: Polygon) {
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
const amapPolygon = new window.AMap.Polygon({
|
||||
path: polygon.path.map(p => [p.lng, p.lat]),
|
||||
fillColor: polygon.fillColor || '#22c55e',
|
||||
strokeColor: polygon.strokeColor || '#166534',
|
||||
fillOpacity: polygon.fillOpacity || 0.3,
|
||||
strokeWeight: polygon.strokeWeight || 2,
|
||||
});
|
||||
|
||||
if (polygon.onClick) {
|
||||
amapPolygon.on('click', polygon.onClick);
|
||||
}
|
||||
|
||||
this.map.add(amapPolygon);
|
||||
this.polygons.set(polygon.id, amapPolygon);
|
||||
} else if (this.provider === 'leaflet' && this.map) {
|
||||
const leafletPolygon = window.L.polygon(
|
||||
polygon.path.map(p => [p.lat, p.lng]),
|
||||
{
|
||||
color: polygon.strokeColor || '#166534',
|
||||
fillColor: polygon.fillColor || '#22c55e',
|
||||
fillOpacity: polygon.fillOpacity || 0.3,
|
||||
weight: polygon.strokeWeight || 2,
|
||||
}
|
||||
).addTo(this.map);
|
||||
|
||||
if (polygon.onClick) {
|
||||
leafletPolygon.on('click', polygon.onClick);
|
||||
}
|
||||
|
||||
this.polygons.set(polygon.id, leafletPolygon);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除标记
|
||||
*/
|
||||
removeMarker(id: string) {
|
||||
const marker = this.markers.get(id);
|
||||
if (!marker) return;
|
||||
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
this.map.remove(marker);
|
||||
} else if (this.provider === 'leaflet') {
|
||||
marker.remove();
|
||||
} else if (this.provider === 'placeholder') {
|
||||
marker.remove();
|
||||
}
|
||||
|
||||
this.markers.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除多边形
|
||||
*/
|
||||
removePolygon(id: string) {
|
||||
const polygon = this.polygons.get(id);
|
||||
if (!polygon) return;
|
||||
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
this.map.remove(polygon);
|
||||
} else if (this.provider === 'leaflet') {
|
||||
polygon.remove();
|
||||
}
|
||||
|
||||
this.polygons.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置中心点
|
||||
*/
|
||||
setCenter(position: MapPosition, zoom?: number) {
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
this.map.setCenter([position.lng, position.lat]);
|
||||
if (zoom) this.map.setZoom(zoom);
|
||||
} else if (this.provider === 'leaflet' && this.map) {
|
||||
this.map.setView([position.lat, position.lng], zoom || this.map.getZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 适应边界
|
||||
*/
|
||||
fitBounds(bounds: MapBounds) {
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
this.map.setBounds(
|
||||
new window.AMap.Bounds(
|
||||
[bounds.southwest.lng, bounds.southwest.lat],
|
||||
[bounds.northeast.lng, bounds.northeast.lat]
|
||||
)
|
||||
);
|
||||
} else if (this.provider === 'leaflet' && this.map) {
|
||||
this.map.fitBounds([
|
||||
[bounds.southwest.lat, bounds.southwest.lng],
|
||||
[bounds.northeast.lat, bounds.northeast.lng]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩放
|
||||
*/
|
||||
setZoom(zoom: number) {
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
this.map.setZoom(zoom);
|
||||
} else if (this.provider === 'leaflet' && this.map) {
|
||||
this.map.setZoom(zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前缩放级别
|
||||
*/
|
||||
getZoom(): number {
|
||||
if (this.provider === 'amap' && this.map) {
|
||||
return this.map.getZoom();
|
||||
} else if (this.provider === 'leaflet' && this.map) {
|
||||
return this.map.getZoom();
|
||||
}
|
||||
return 13; // 默认缩放
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有标记
|
||||
*/
|
||||
clearMarkers() {
|
||||
this.markers.forEach((marker, id) => {
|
||||
this.removeMarker(id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有多边形
|
||||
*/
|
||||
clearPolygons() {
|
||||
this.polygons.forEach((polygon, id) => {
|
||||
this.removePolygon(id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有覆盖物
|
||||
*/
|
||||
clearAll() {
|
||||
this.clearMarkers();
|
||||
this.clearPolygons();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁地图
|
||||
*/
|
||||
destroy() {
|
||||
this.clearAll();
|
||||
|
||||
if (this.map) {
|
||||
if (this.provider === 'amap') {
|
||||
this.map.destroy();
|
||||
} else if (this.provider === 'leaflet') {
|
||||
this.map.remove();
|
||||
}
|
||||
this.map = null;
|
||||
}
|
||||
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取地图实例
|
||||
*/
|
||||
getMapInstance() {
|
||||
return this.map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前提供商
|
||||
*/
|
||||
getProvider(): MapProvider {
|
||||
return this.provider;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
AMap: any;
|
||||
L: any;
|
||||
_AMapSecurityConfig: any;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,41 @@
|
||||
// 农机档案数据存储管理
|
||||
import { MachineryRecord, MachineryChangeHistory, MachineryTag } from '../types/machinery';
|
||||
import { MachineryRecord, MachineryChangeHistory, MachineryTag, MaintenanceRecord } from '../types/machinery';
|
||||
|
||||
const MACHINERY_KEY = 'smart_agriculture_machinery';
|
||||
const HISTORY_KEY = 'smart_agriculture_machinery_history';
|
||||
const TAGS_KEY = 'smart_agriculture_machinery_tags';
|
||||
const MAINTENANCE_KEY = 'smart_agriculture_machinery_maintenance';
|
||||
|
||||
// 数据迁移:将旧状态值转换为新状态值
|
||||
function migrateStatusValue(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'运行中': '正常',
|
||||
'空闲中': '正常',
|
||||
'维修中': '待维护',
|
||||
'报废': '已报废',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
export const machineryStorage = {
|
||||
// 获取所有农机档案
|
||||
getAllMachinery(): MachineryRecord[] {
|
||||
const data = localStorage.getItem(MACHINERY_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
if (!data) return [];
|
||||
|
||||
const machinery: MachineryRecord[] = JSON.parse(data);
|
||||
// 自动迁移旧状态值
|
||||
const migrated = machinery.map(m => ({
|
||||
...m,
|
||||
status: migrateStatusValue(m.status)
|
||||
}));
|
||||
|
||||
// 如果有数据被迁移,保存回localStorage
|
||||
if (JSON.stringify(machinery) !== JSON.stringify(migrated)) {
|
||||
localStorage.setItem(MACHINERY_KEY, JSON.stringify(migrated));
|
||||
}
|
||||
|
||||
return migrated;
|
||||
},
|
||||
|
||||
// 获取单个农机档案
|
||||
@@ -81,5 +107,88 @@ export const machineryStorage = {
|
||||
getMachineryByQRCode(qrCode: string): MachineryRecord | undefined {
|
||||
const all = this.getAllMachinery();
|
||||
return all.find(m => m.qrCode === qrCode);
|
||||
},
|
||||
|
||||
// 获取某个农机的所有维护记录
|
||||
getMaintenanceRecords(machineryId: string): MaintenanceRecord[] {
|
||||
const data = localStorage.getItem(MAINTENANCE_KEY);
|
||||
const all: MaintenanceRecord[] = data ? JSON.parse(data) : [];
|
||||
|
||||
// 数据迁移:将旧格式的partsAndMaterials(string[])转换为新格式(MaterialUsage[])
|
||||
const migrated = all.map(record => {
|
||||
if (record.partsAndMaterials && record.partsAndMaterials.length > 0) {
|
||||
// 检查是否是旧格式(string数组)
|
||||
const firstItem = record.partsAndMaterials[0];
|
||||
if (typeof firstItem === 'string') {
|
||||
// 旧格式,转换为新格式
|
||||
return {
|
||||
...record,
|
||||
partsAndMaterials: (record.partsAndMaterials as any[]).map((id: string) => ({
|
||||
materialId: id,
|
||||
quantity: 1
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
return record;
|
||||
});
|
||||
|
||||
// 如果有数据被迁移,保存回localStorage
|
||||
if (JSON.stringify(all) !== JSON.stringify(migrated)) {
|
||||
localStorage.setItem(MAINTENANCE_KEY, JSON.stringify(migrated));
|
||||
}
|
||||
|
||||
return migrated.filter(m => m.machineryId === machineryId).sort((a, b) =>
|
||||
new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
|
||||
);
|
||||
},
|
||||
|
||||
// 获取所有维护记录
|
||||
getAllMaintenanceRecords(): MaintenanceRecord[] {
|
||||
const data = localStorage.getItem(MAINTENANCE_KEY);
|
||||
const all: MaintenanceRecord[] = data ? JSON.parse(data) : [];
|
||||
|
||||
// 数据迁移:将旧格式的partsAndMaterials(string[])转换为新格式(MaterialUsage[])
|
||||
const migrated = all.map(record => {
|
||||
if (record.partsAndMaterials && record.partsAndMaterials.length > 0) {
|
||||
const firstItem = record.partsAndMaterials[0];
|
||||
if (typeof firstItem === 'string') {
|
||||
return {
|
||||
...record,
|
||||
partsAndMaterials: (record.partsAndMaterials as any[]).map((id: string) => ({
|
||||
materialId: id,
|
||||
quantity: 1
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
return record;
|
||||
});
|
||||
|
||||
// 如果有数据被迁移,保存回localStorage
|
||||
if (JSON.stringify(all) !== JSON.stringify(migrated)) {
|
||||
localStorage.setItem(MAINTENANCE_KEY, JSON.stringify(migrated));
|
||||
}
|
||||
|
||||
return migrated;
|
||||
},
|
||||
|
||||
// 保存维护记录
|
||||
saveMaintenanceRecord(record: MaintenanceRecord): void {
|
||||
const all = this.getAllMaintenanceRecords();
|
||||
const index = all.findIndex(m => m.id === record.id);
|
||||
if (index >= 0) {
|
||||
all[index] = record;
|
||||
} else {
|
||||
all.push(record);
|
||||
}
|
||||
localStorage.setItem(MAINTENANCE_KEY, JSON.stringify(all));
|
||||
},
|
||||
|
||||
// 删除维护记录
|
||||
deleteMaintenanceRecord(id: string): void {
|
||||
const all = this.getAllMaintenanceRecords();
|
||||
const filtered = all.filter(m => m.id !== id);
|
||||
localStorage.setItem(MAINTENANCE_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
};
|
||||
|
||||
320
src/lib/maintenanceMockData.ts
Normal file
320
src/lib/maintenanceMockData.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
// 维护记录示例数据
|
||||
import { MaintenanceRecord, MaterialUsage } from '../types/machinery';
|
||||
import { machineryStorage } from './machineryStorage';
|
||||
|
||||
/**
|
||||
* 初始化维护记录示例数据
|
||||
* 包含多种维护类型、配件使用、维护场景等
|
||||
*/
|
||||
export function initializeMaintenanceMockData() {
|
||||
// 检查是否已有数据(避免重复初始化)
|
||||
const allRecords = machineryStorage.getAllMaintenanceRecords();
|
||||
if (allRecords.length > 0) {
|
||||
return; // 已有数据
|
||||
}
|
||||
|
||||
// 获取所有农机,为前3台农机创建维护记录
|
||||
const allMachinery = machineryStorage.getAllMachinery();
|
||||
if (allMachinery.length === 0) {
|
||||
return; // 没有农机,无法创建维护记录
|
||||
}
|
||||
|
||||
const mockRecords: MaintenanceRecord[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// ==================== 农机1:约翰迪尔拖拉机维护记录 ====================
|
||||
if (allMachinery[0]) {
|
||||
const machinery1Id = allMachinery[0].id;
|
||||
|
||||
// 1. 日常保养记录
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-001`,
|
||||
machineryId: machinery1Id,
|
||||
type: '日常保养',
|
||||
startTime: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7天前
|
||||
endTime: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000 + 2 * 60 * 60 * 1000).toISOString(), // 持续2小时
|
||||
workHours: 2,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 23 * 24 * 60 * 60 * 1000).toISOString(), // 23天后
|
||||
maintenanceItems: '更换机油、机油滤芯,检查空气滤芯,润滑各部位,检查轮胎气压,清洁设备外观',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-oil-001', quantity: 15 }, // 液压油 15升
|
||||
{ materialId: 'material-filter-001', quantity: 2 }, // 机油滤芯 2个
|
||||
],
|
||||
cost: 850,
|
||||
technician: '李师傅',
|
||||
remarks: '定期保养,设备运行正常,无异常发现',
|
||||
createdAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
|
||||
// 2. 定期维护记录
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-002`,
|
||||
machineryId: machinery1Id,
|
||||
type: '定期维护',
|
||||
startTime: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(), // 45天前
|
||||
endTime: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000 + 4 * 60 * 60 * 1000).toISOString(), // 持续4小时
|
||||
workHours: 4,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 135 * 24 * 60 * 60 * 1000).toISOString(), // 135天后(半年维护)
|
||||
maintenanceItems: '更换空气滤芯、燃油滤芯,检查刹车系统,调整传动皮带,检查液压系统,校准作业深度传感器,全面检查电气系统',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-filter-002', quantity: 1 }, // 空气滤芯 1个
|
||||
{ materialId: 'material-filter-003', quantity: 1 }, // 燃油滤芯 1个
|
||||
{ materialId: 'material-oil-001', quantity: 20 }, // 液压油 20升
|
||||
{ materialId: 'material-parts-001', quantity: 2 }, // 传动皮带 2条
|
||||
],
|
||||
cost: 2380,
|
||||
technician: '王工',
|
||||
remarks: '半年度定期维护,更换了老化的传动皮带,刹车系统调整正常,液压系统压力正常',
|
||||
createdAt: new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
|
||||
// 3. 故障维修记录
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-003`,
|
||||
machineryId: machinery1Id,
|
||||
type: '故障维修',
|
||||
startTime: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(), // 20天前
|
||||
endTime: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000 + 6 * 60 * 60 * 1000).toISOString(), // 持续6小时
|
||||
workHours: 6,
|
||||
maintenanceItems: '修复液压系统漏油问题,更换损坏的油封和O型圈,检查并紧固各连接部位,清洗液压油箱,补充液压油',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-parts-002', quantity: 3 }, // 油封 3个
|
||||
{ materialId: 'material-parts-003', quantity: 5 }, // O型圈 5个
|
||||
{ materialId: 'material-oil-001', quantity: 8 }, // 液压油 8升
|
||||
],
|
||||
cost: 1560,
|
||||
technician: '李师傅、张技师',
|
||||
remarks: '发现液压油管接头处油封老化导致漏油,已更换新油封,测试正常。建议加强日常检查。',
|
||||
createdAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
|
||||
// 4. 年检记录
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-004`,
|
||||
machineryId: machinery1Id,
|
||||
type: '年检',
|
||||
startTime: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(), // 90天前
|
||||
endTime: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString(), // 持续3小时
|
||||
workHours: 3,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 275 * 24 * 60 * 60 * 1000).toISOString(), // 275天后(下次年检)
|
||||
maintenanceItems: '年度安全检查,排放检测,噪音测试,制动性能测试,灯光系统检查,安全装置检查,更换易损件',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-parts-004', quantity: 4 }, // 刹车片 4片
|
||||
{ materialId: 'material-parts-005', quantity: 2 }, // 灯泡 2个
|
||||
],
|
||||
cost: 1200,
|
||||
technician: '农机监理站-刘检验员',
|
||||
remarks: '年检合格,所有安全项目符合标准。建议更换刹车片,已现场更换。年检证书编号:AJ-2024-001',
|
||||
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 农机2:收割机维护记录 ====================
|
||||
if (allMachinery[1]) {
|
||||
const machinery2Id = allMachinery[1].id;
|
||||
|
||||
// 1. 作业季前检修
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-005`,
|
||||
machineryId: machinery2Id,
|
||||
type: '定期维护',
|
||||
startTime: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(), // 60天前
|
||||
endTime: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000 + 8 * 60 * 60 * 1000).toISOString(), // 持续8小时
|
||||
workHours: 8,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maintenanceItems: '收获季节前全面检修:更换刀片组件,调整脱粒滚筒间隙,清理筛网,检查传动链条张紧度,润滑各轴承点,检查液压升降系统,更换磨损严重的传动带',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-parts-006', quantity: 8 }, // 收割刀片 8片
|
||||
{ materialId: 'material-parts-001', quantity: 3 }, // 传动皮带 3条
|
||||
{ materialId: 'material-oil-002', quantity: 5 }, // 齿轮油 5升
|
||||
{ materialId: 'material-parts-007', quantity: 1 }, // 传动链条 1条
|
||||
],
|
||||
cost: 4200,
|
||||
technician: '赵师傅、孙师傅',
|
||||
remarks: '收获季节前全面检修,刀片已全部更换为新件,脱粒系统调整到最佳状态,试运行正常。',
|
||||
createdAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
|
||||
// 2. 作业中故障抢修
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-006`,
|
||||
machineryId: machinery2Id,
|
||||
type: '故障维修',
|
||||
startTime: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30天前
|
||||
endTime: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000 + 5 * 60 * 60 * 1000).toISOString(), // 持续5小时
|
||||
workHours: 5,
|
||||
maintenanceItems: '紧急抢修:更换损坏的传动轴承,修复电气线路短路问题,调整清粮风机,紧固松动的螺栓',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-parts-008', quantity: 2 }, // 轴承 2个
|
||||
{ materialId: 'material-parts-009', quantity: 10 }, // 电线 10米
|
||||
{ materialId: 'material-parts-010', quantity: 20 }, // 螺栓螺母 20套
|
||||
],
|
||||
cost: 1850,
|
||||
technician: '李师傅、王工(紧急出车)',
|
||||
remarks: '收割作业中突发故障,传动轴承损坏导致异响。现场紧急更换轴承,检查发现部分电气线路老化短路,一并处理。抢修后恢复作业。',
|
||||
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
|
||||
// 3. 作业季后保养
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-007`,
|
||||
machineryId: machinery2Id,
|
||||
type: '日常保养',
|
||||
startTime: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(), // 10天前
|
||||
endTime: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000 + 4 * 60 * 60 * 1000).toISOString(), // 持续4小时
|
||||
workHours: 4,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maintenanceItems: '收获季节后保养:全面清理机身杂物和积尘,更换机油和滤芯,检查所有润滑点,防锈处理,存放准备',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-oil-003', quantity: 18 }, // 机油 18升
|
||||
{ materialId: 'material-filter-001', quantity: 1 }, // 机油滤芯 1个
|
||||
{ materialId: 'material-filter-002', quantity: 1 }, // 空气滤芯 1个
|
||||
{ materialId: 'material-parts-011', quantity: 2 }, // 防锈剂 2瓶
|
||||
],
|
||||
cost: 950,
|
||||
technician: '张师傅',
|
||||
remarks: '收获季节结束后的保养,设备已清洗干净,关键部位已涂抹防锈剂,准备入库存放。',
|
||||
createdAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 农机3:播种机维护记录 ====================
|
||||
if (allMachinery[2]) {
|
||||
const machinery3Id = allMachinery[2].id;
|
||||
|
||||
// 1. 播种前调试
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-008`,
|
||||
machineryId: machinery3Id,
|
||||
type: '定期维护',
|
||||
startTime: new Date(now.getTime() - 50 * 24 * 60 * 60 * 1000).toISOString(), // 50天前
|
||||
endTime: new Date(now.getTime() - 50 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString(), // 持续3小时
|
||||
workHours: 3,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 40 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maintenanceItems: '播种前调试:检查并调整排种器间距,校准播种深度,检查施肥装置,清洗种子箱,润滑传动部件,调整镇压轮',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-parts-012', quantity: 6 }, // 排种轮 6个
|
||||
{ materialId: 'material-oil-004', quantity: 3 }, // 润滑脂 3kg
|
||||
],
|
||||
cost: 680,
|
||||
technician: '赵师傅',
|
||||
remarks: '播种前例行调试,排种器调整到最佳状态,播种深度校准完成,试播效果良好。',
|
||||
createdAt: new Date(now.getTime() - 50 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
|
||||
// 2. 日常保养
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-009`,
|
||||
machineryId: machinery3Id,
|
||||
type: '日常保养',
|
||||
startTime: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000).toISOString(), // 15天前
|
||||
endTime: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000 + 2 * 60 * 60 * 1000).toISOString(), // 持续2小时
|
||||
workHours: 2,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maintenanceItems: '作业中保养:清理种子箱残留,检查排种器磨损情况,润滑各活动部件,紧固松动螺栓',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-oil-004', quantity: 1 }, // 润滑脂 1kg
|
||||
],
|
||||
cost: 150,
|
||||
technician: '李师傅',
|
||||
remarks: '播种作业中的日常保养,设备运行正常,无异常磨损。',
|
||||
createdAt: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
|
||||
// 3. 小修记录
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-010`,
|
||||
machineryId: machinery3Id,
|
||||
type: '故障维修',
|
||||
startTime: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(), // 5天前
|
||||
endTime: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000 + 1.5 * 60 * 60 * 1000).toISOString(), // 持续1.5小时
|
||||
workHours: 1.5,
|
||||
maintenanceItems: '更换磨损的开沟器刀片,调整施肥管道,修复堵塞的排种孔',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-parts-013', quantity: 4 }, // 开沟器刀片 4个
|
||||
],
|
||||
cost: 420,
|
||||
technician: '张师傅',
|
||||
remarks: '开沟器刀片磨损严重影响作业质量,现场更换新刀片,调整后播种深度一致性良好。',
|
||||
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 通用维护案例 ====================
|
||||
// 如果有更多农机,添加更多示例
|
||||
if (allMachinery[3]) {
|
||||
const machinery4Id = allMachinery[3].id;
|
||||
|
||||
// 预防性维护示例
|
||||
mockRecords.push({
|
||||
id: `maintenance-${Date.now()}-011`,
|
||||
machineryId: machinery4Id,
|
||||
type: '定期维护',
|
||||
startTime: new Date(now.getTime() - 25 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endTime: new Date(now.getTime() - 25 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString(),
|
||||
workHours: 3,
|
||||
nextMaintenanceTime: new Date(now.getTime() + 65 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maintenanceItems: '预防性维护:全面检查关键部件,更换易损件,系统清洁,性能测试',
|
||||
partsAndMaterials: [
|
||||
{ materialId: 'material-filter-001', quantity: 1 },
|
||||
{ materialId: 'material-filter-002', quantity: 1 },
|
||||
{ materialId: 'material-oil-001', quantity: 10 },
|
||||
],
|
||||
cost: 1100,
|
||||
technician: '王工',
|
||||
remarks: '按照预防性维护计划执行,提前发现并处理了潜在故障点,确保设备可靠运行。',
|
||||
createdAt: new Date(now.getTime() - 25 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: '系统管理员',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存所有维护记录
|
||||
mockRecords.forEach(record => {
|
||||
machineryStorage.saveMaintenanceRecord(record);
|
||||
});
|
||||
|
||||
console.log(`✅ 已初始化 ${mockRecords.length} 条维护记录示例数据`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有维护记录(仅用于测试)
|
||||
*/
|
||||
export function clearMaintenanceRecords() {
|
||||
localStorage.removeItem('smart_agriculture_machinery_maintenance');
|
||||
console.log('🗑️ 已清除所有维护记录');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取维护记录统计信息
|
||||
*/
|
||||
export function getMaintenanceStatistics() {
|
||||
const records = machineryStorage.getAllMaintenanceRecords();
|
||||
|
||||
const stats = {
|
||||
total: records.length,
|
||||
byType: {
|
||||
'日常保养': records.filter(r => r.type === '日常保养').length,
|
||||
'定期维护': records.filter(r => r.type === '定期维护').length,
|
||||
'故障维修': records.filter(r => r.type === '故障维修').length,
|
||||
'年检': records.filter(r => r.type === '年检').length,
|
||||
},
|
||||
totalCost: records.reduce((sum, r) => sum + r.cost, 0),
|
||||
totalWorkHours: records.reduce((sum, r) => sum + r.workHours, 0),
|
||||
avgCostPerRecord: records.length > 0 ? records.reduce((sum, r) => sum + r.cost, 0) / records.length : 0,
|
||||
recentRecords: records
|
||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
||||
.slice(0, 5),
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
127
src/lib/mapLoader.ts
Normal file
127
src/lib/mapLoader.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 高德地图SDK动态加载器
|
||||
* 用于在不修改index.html的情况下加载高德地图SDK
|
||||
*/
|
||||
|
||||
// 高德地图配置
|
||||
const AMAP_CONFIG = {
|
||||
// 替换为你的高德地图API Key
|
||||
// 申请地址: https://console.amap.com/
|
||||
key: 'YOUR_AMAP_KEY',
|
||||
|
||||
// 替换为你的安全密钥
|
||||
securityJsCode: 'YOUR_SECURITY_JS_CODE',
|
||||
|
||||
// SDK版本
|
||||
version: '2.0',
|
||||
|
||||
// 可选插件
|
||||
plugins: [] as string[],
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载高德地图SDK
|
||||
* @returns Promise<any> 返回AMap对象或null(占位模式)
|
||||
*/
|
||||
export const loadAMapScript = (): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 如果已经加载,直接返回
|
||||
if (window.AMap) {
|
||||
console.log('✅ 高德地图SDK已加载');
|
||||
resolve(window.AMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查Key是否配置
|
||||
if (AMAP_CONFIG.key === 'YOUR_AMAP_KEY') {
|
||||
// 使用占位地图(功能完整)
|
||||
console.log('💡 使用占位地图模式(功能完整)');
|
||||
console.log('💡 如需真实地图,请查看 /MAP_SDK_QUICK_FIX.md');
|
||||
resolve(null); // 返回null表示使用占位地图
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置安全密钥
|
||||
window._AMapSecurityConfig = {
|
||||
securityJsCode: AMAP_CONFIG.securityJsCode,
|
||||
};
|
||||
|
||||
// 创建script标签
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
|
||||
// 构建SDK URL
|
||||
let url = `https://webapi.amap.com/maps?v=${AMAP_CONFIG.version}&key=${AMAP_CONFIG.key}`;
|
||||
|
||||
// 添加插件
|
||||
if (AMAP_CONFIG.plugins.length > 0) {
|
||||
url += `&plugin=${AMAP_CONFIG.plugins.join(',')}`;
|
||||
}
|
||||
|
||||
script.src = url;
|
||||
|
||||
// 加载成功
|
||||
script.onload = () => {
|
||||
console.log('✅ 高德地图SDK加载成功');
|
||||
console.log('📍 版本:', window.AMap?.version);
|
||||
resolve();
|
||||
};
|
||||
|
||||
// 加载失败
|
||||
script.onerror = () => {
|
||||
console.error('❌ 高德地图SDK加载失败');
|
||||
reject(new Error('高德地图SDK加载失败'));
|
||||
};
|
||||
|
||||
// 添加到页面
|
||||
document.head.appendChild(script);
|
||||
|
||||
console.log('🔄 正在加载高德地图SDK...');
|
||||
} catch (error) {
|
||||
console.error('❌ 加载高德地图SDK时发生错误:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查高德地图SDK是否已加载
|
||||
* @returns boolean
|
||||
*/
|
||||
export const isAMapLoaded = (): boolean => {
|
||||
return typeof window !== 'undefined' && !!window.AMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取高德地图版本
|
||||
* @returns string | null
|
||||
*/
|
||||
export const getAMapVersion = (): string | null => {
|
||||
if (isAMapLoaded()) {
|
||||
return window.AMap.version || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用示例:
|
||||
*
|
||||
* import { loadAMapScript, isAMapLoaded } from './lib/mapLoader';
|
||||
*
|
||||
* // 在组件中使用
|
||||
* useEffect(() => {
|
||||
* if (!isAMapLoaded()) {
|
||||
* loadAMapScript()
|
||||
* .then(() => {
|
||||
* console.log('地图SDK加载成功,可以初始化地图');
|
||||
* initMap();
|
||||
* })
|
||||
* .catch((error) => {
|
||||
* console.error('地图SDK加载失败,使用占位地图', error);
|
||||
* });
|
||||
* } else {
|
||||
* initMap();
|
||||
* }
|
||||
* }, []);
|
||||
*/
|
||||
197
src/lib/materialStorage.ts
Normal file
197
src/lib/materialStorage.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// 农资物料数据存储管理
|
||||
import { Material } from '../types/machinery';
|
||||
|
||||
const MATERIAL_KEY = 'smart_agriculture_materials';
|
||||
|
||||
export const materialStorage = {
|
||||
// 获取所有农资物料
|
||||
getAllMaterials(): Material[] {
|
||||
const data = localStorage.getItem(MATERIAL_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
},
|
||||
|
||||
// 获取单个农资物料
|
||||
getMaterial(id: string): Material | undefined {
|
||||
const all = this.getAllMaterials();
|
||||
return all.find(m => m.id === id);
|
||||
},
|
||||
|
||||
// 保存农资物料
|
||||
saveMaterial(material: Material): void {
|
||||
const all = this.getAllMaterials();
|
||||
const index = all.findIndex(m => m.id === material.id);
|
||||
if (index >= 0) {
|
||||
all[index] = material;
|
||||
} else {
|
||||
all.push(material);
|
||||
}
|
||||
localStorage.setItem(MATERIAL_KEY, JSON.stringify(all));
|
||||
},
|
||||
|
||||
// 删除农资物料
|
||||
deleteMaterial(id: string): void {
|
||||
const all = this.getAllMaterials();
|
||||
const filtered = all.filter(m => m.id !== id);
|
||||
localStorage.setItem(MATERIAL_KEY, JSON.stringify(filtered));
|
||||
},
|
||||
|
||||
// 批量保存农资物料
|
||||
saveMaterials(materials: Material[]): void {
|
||||
localStorage.setItem(MATERIAL_KEY, JSON.stringify(materials));
|
||||
},
|
||||
|
||||
// 根据类型获取农资
|
||||
getMaterialsByType(type: string): Material[] {
|
||||
const all = this.getAllMaterials();
|
||||
return all.filter(m => m.type === type);
|
||||
},
|
||||
|
||||
// 获取有库存的农资(用于维护记录选择)
|
||||
getAvailableMaterials(): Material[] {
|
||||
const all = this.getAllMaterials();
|
||||
return all.filter(m => m.currentStock > 0 && m.status !== '已过期');
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化示例农资数据
|
||||
export function initializeMaterialData() {
|
||||
const existing = materialStorage.getAllMaterials();
|
||||
if (existing.length > 0) {
|
||||
return; // 已有数据,不重复初始化
|
||||
}
|
||||
|
||||
const mockMaterials: Material[] = [
|
||||
{
|
||||
id: 'material-1',
|
||||
code: 'PJ-001',
|
||||
name: '机油滤芯',
|
||||
type: '配件',
|
||||
spec: '10W-40',
|
||||
model: 'JD-001',
|
||||
supplier: '约翰迪尔配件中心',
|
||||
currentStock: 50,
|
||||
unit: '个',
|
||||
purchasePrice: 85,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-2',
|
||||
code: 'PJ-002',
|
||||
name: '空气滤芯',
|
||||
type: '配件',
|
||||
spec: '标准型',
|
||||
model: 'AF-2024',
|
||||
supplier: '农机配件商行',
|
||||
currentStock: 30,
|
||||
unit: '个',
|
||||
purchasePrice: 65,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-3',
|
||||
code: 'HC-001',
|
||||
name: '液压油',
|
||||
type: '耗材',
|
||||
spec: '46号',
|
||||
model: 'HM46',
|
||||
supplier: '中石化润滑油',
|
||||
currentStock: 200,
|
||||
unit: '升',
|
||||
purchasePrice: 28,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-4',
|
||||
code: 'HC-002',
|
||||
name: '齿轮油',
|
||||
type: '耗材',
|
||||
spec: '85W-90',
|
||||
model: 'GL-5',
|
||||
supplier: '壳牌石油',
|
||||
currentStock: 150,
|
||||
unit: '升',
|
||||
purchasePrice: 45,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-5',
|
||||
code: 'PJ-003',
|
||||
name: '燃油滤芯',
|
||||
type: '配件',
|
||||
spec: '柴油专用',
|
||||
model: 'FS-1000',
|
||||
supplier: '康明斯配件',
|
||||
currentStock: 25,
|
||||
unit: '个',
|
||||
purchasePrice: 95,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-6',
|
||||
code: 'HC-003',
|
||||
name: '防冻液',
|
||||
type: '耗材',
|
||||
spec: '-35℃',
|
||||
model: 'AF-35',
|
||||
supplier: '汽车用品店',
|
||||
currentStock: 80,
|
||||
unit: '升',
|
||||
purchasePrice: 18,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-7',
|
||||
code: 'PJ-004',
|
||||
name: '传动皮带',
|
||||
type: '配件',
|
||||
spec: 'A型',
|
||||
model: 'A-1200',
|
||||
supplier: '三星皮带',
|
||||
currentStock: 15,
|
||||
unit: '条',
|
||||
purchasePrice: 120,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-8',
|
||||
code: 'HC-004',
|
||||
name: '黄油(润滑脂)',
|
||||
type: '耗材',
|
||||
spec: '锂基脂',
|
||||
model: 'MP-3',
|
||||
supplier: '长城润滑油',
|
||||
currentStock: 60,
|
||||
unit: '公斤',
|
||||
purchasePrice: 32,
|
||||
status: '正常',
|
||||
},
|
||||
{
|
||||
id: 'material-9',
|
||||
code: 'PJ-005',
|
||||
name: '刀片组',
|
||||
type: '配件',
|
||||
spec: '收割机专用',
|
||||
model: 'KB-688',
|
||||
supplier: '久保田配件',
|
||||
currentStock: 8,
|
||||
unit: '套',
|
||||
purchasePrice: 580,
|
||||
status: '库存预警',
|
||||
},
|
||||
{
|
||||
id: 'material-10',
|
||||
code: 'HC-005',
|
||||
name: '清洗剂',
|
||||
type: '耗材',
|
||||
spec: '工业级',
|
||||
model: 'CL-100',
|
||||
supplier: '化工材料店',
|
||||
currentStock: 40,
|
||||
unit: '升',
|
||||
purchasePrice: 22,
|
||||
status: '正常',
|
||||
},
|
||||
];
|
||||
|
||||
materialStorage.saveMaterials(mockMaterials);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
// 初始化示例数据
|
||||
import { MachineryRecord, MachineryTag } from '../types/machinery';
|
||||
import { machineryStorage } from './machineryStorage';
|
||||
import { initializeMaintenanceMockData } from './maintenanceMockData';
|
||||
import { initializeChangeHistoryMockData } from './changeHistoryMockData';
|
||||
|
||||
export function initializeMockData() {
|
||||
// 检查是否已有数据
|
||||
const existingMachinery = machineryStorage.getAllMachinery();
|
||||
if (existingMachinery.length > 0) {
|
||||
return; // 已有数据,不重复初始化
|
||||
// 已有农机数据,但可能没有维护记录和变更历史,尝试初始化
|
||||
initializeMaintenanceMockData();
|
||||
initializeChangeHistoryMockData();
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化标签
|
||||
@@ -43,10 +48,12 @@ export function initializeMockData() {
|
||||
insuranceStartDate: '2024-04-01',
|
||||
insuranceEndDate: '2025-03-31',
|
||||
insuranceAmount: 300000,
|
||||
status: '运行中',
|
||||
status: '正常',
|
||||
currentLocation: '1号地块',
|
||||
operator: '张三',
|
||||
department: '第一生产队',
|
||||
maintenanceCycle: 3,
|
||||
maintenanceCycleUnit: 'month',
|
||||
remarks: '主力耕作设备,状态良好',
|
||||
tags: ['tag-1', 'tag-2'],
|
||||
qrCode: 'machinery-1',
|
||||
@@ -77,10 +84,12 @@ export function initializeMockData() {
|
||||
insuranceStartDate: '2024-06-01',
|
||||
insuranceEndDate: '2025-05-31',
|
||||
insuranceAmount: 250000,
|
||||
status: '空闲中',
|
||||
status: '正常',
|
||||
currentLocation: '机库A区',
|
||||
operator: '李四',
|
||||
department: '第二生产队',
|
||||
maintenanceCycle: 50,
|
||||
maintenanceCycleUnit: 'day',
|
||||
remarks: '水稻收割专用,效率高',
|
||||
tags: ['tag-2', 'tag-3'],
|
||||
qrCode: 'machinery-2',
|
||||
@@ -115,6 +124,8 @@ export function initializeMockData() {
|
||||
currentLocation: '维修车间',
|
||||
operator: '王五',
|
||||
department: '第一生产队',
|
||||
maintenanceCycle: 1,
|
||||
maintenanceCycleUnit: 'month',
|
||||
remarks: '智能播种,需要定期校准',
|
||||
tags: ['tag-2', 'tag-4'],
|
||||
qrCode: 'machinery-3',
|
||||
@@ -145,10 +156,12 @@ export function initializeMockData() {
|
||||
insuranceStartDate: '2024-02-01',
|
||||
insuranceEndDate: '2025-01-31',
|
||||
insuranceAmount: 50000,
|
||||
status: '运行中',
|
||||
status: '正常',
|
||||
currentLocation: '5号地块',
|
||||
operator: '赵六',
|
||||
department: '植保组',
|
||||
maintenanceCycle: 15,
|
||||
maintenanceCycleUnit: 'day',
|
||||
remarks: '高效植保,续航40分钟',
|
||||
tags: ['tag-1', 'tag-2', 'tag-3'],
|
||||
qrCode: 'machinery-4',
|
||||
@@ -179,10 +192,12 @@ export function initializeMockData() {
|
||||
insuranceStartDate: '2023-12-01',
|
||||
insuranceEndDate: '2024-11-30',
|
||||
insuranceAmount: 280000,
|
||||
status: '运行中',
|
||||
status: '正常',
|
||||
currentLocation: '3号地块',
|
||||
operator: '孙七',
|
||||
department: '第三生产队',
|
||||
maintenanceCycle: 6,
|
||||
maintenanceCycleUnit: 'month',
|
||||
remarks: '国产品牌,性价比高',
|
||||
tags: ['tag-2'],
|
||||
qrCode: 'machinery-5',
|
||||
@@ -194,4 +209,12 @@ export function initializeMockData() {
|
||||
];
|
||||
|
||||
mockMachinery.forEach(machinery => machineryStorage.saveMachinery(machinery));
|
||||
|
||||
// 初始化维护记录示例数据
|
||||
initializeMaintenanceMockData();
|
||||
|
||||
// 初始化变更历史示例数据
|
||||
initializeChangeHistoryMockData();
|
||||
|
||||
console.log('✅ 农机档案、维护记录和变更历史示例数据初始化完成');
|
||||
}
|
||||
|
||||
583
src/lib/satelliteImageService.ts
Normal file
583
src/lib/satelliteImageService.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* 遥感影像服务
|
||||
* 集成天地图、Sentinel、Landsat等数据源
|
||||
*/
|
||||
|
||||
// ===== 类型定义 =====
|
||||
|
||||
export interface SatelliteImage {
|
||||
id: string;
|
||||
date: string;
|
||||
source: 'Sentinel-2' | 'Landsat-8' | 'Landsat-9' | '天地图' | 'GF-1' | 'GF-2';
|
||||
cloudCover: number; // 云量百分比
|
||||
resolution: number; // 分辨率(米)
|
||||
ndvi: number; // 归一化植被指数
|
||||
evi: number; // 增强型植被指数
|
||||
savi: number; // 土壤调节植被指数
|
||||
lai: number; // 叶面积指数
|
||||
thumbnail: string; // 缩略图URL
|
||||
fullImageUrl: string; // 完整影像URL
|
||||
season: string;
|
||||
quality: 'excellent' | 'good' | 'fair' | 'poor';
|
||||
bands: {
|
||||
red?: string;
|
||||
green?: string;
|
||||
blue?: string;
|
||||
nir?: string;
|
||||
swir?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldBoundary {
|
||||
id: string;
|
||||
name: string;
|
||||
coordinates: [number, number][];
|
||||
area: number; // 亩
|
||||
}
|
||||
|
||||
export interface ImageComparisonResult {
|
||||
image1: SatelliteImage;
|
||||
image2: SatelliteImage;
|
||||
ndviChange: number;
|
||||
eviChange: number;
|
||||
changeType: 'improvement' | 'decline' | 'stable';
|
||||
changeDescription: string;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface TimeSeriesAnalysis {
|
||||
dates: string[];
|
||||
ndviValues: number[];
|
||||
eviValues: number[];
|
||||
trend: 'increasing' | 'decreasing' | 'fluctuating' | 'stable';
|
||||
growthStage: string;
|
||||
healthScore: number;
|
||||
alerts: string[];
|
||||
}
|
||||
|
||||
// ===== 常量定义 =====
|
||||
|
||||
// 数据源配置
|
||||
export const DATA_SOURCES = {
|
||||
'Sentinel-2': {
|
||||
name: 'Sentinel-2 (哨兵2号)',
|
||||
resolution: 10, // 米
|
||||
revisitTime: 5, // 重访周期(天)
|
||||
bands: ['B2-Blue', 'B3-Green', 'B4-Red', 'B8-NIR'],
|
||||
apiEndpoint: 'https://sentinelsat.esa.int/api',
|
||||
description: 'ESA哨兵2号卫星,10米分辨率,5天重访'
|
||||
},
|
||||
'Landsat-8': {
|
||||
name: 'Landsat-8',
|
||||
resolution: 30,
|
||||
revisitTime: 16,
|
||||
bands: ['B2-Blue', 'B3-Green', 'B4-Red', 'B5-NIR'],
|
||||
apiEndpoint: 'https://earthexplorer.usgs.gov/api',
|
||||
description: 'NASA陆地卫星8号,30米分辨率,16天重访'
|
||||
},
|
||||
'天地图': {
|
||||
name: '天地图',
|
||||
resolution: 2,
|
||||
revisitTime: 1,
|
||||
bands: ['RGB'],
|
||||
apiEndpoint: 'https://t0.tianditu.gov.cn/img_w/wmts',
|
||||
description: '国家地理信息公共服务平台,高分辨率影像'
|
||||
},
|
||||
'GF-1': {
|
||||
name: '高分一号',
|
||||
resolution: 2,
|
||||
revisitTime: 4,
|
||||
bands: ['B1-Blue', 'B2-Green', 'B3-Red', 'B4-NIR'],
|
||||
apiEndpoint: 'https://data.cresda.cn/api',
|
||||
description: '中国高分辨率对地观测卫星,2米分辨率'
|
||||
}
|
||||
};
|
||||
|
||||
// NDVI评价标准
|
||||
export const NDVI_THRESHOLDS = {
|
||||
excellent: 0.8, // 优秀
|
||||
good: 0.6, // 良好
|
||||
fair: 0.4, // 一般
|
||||
poor: 0.2 // 较差
|
||||
};
|
||||
|
||||
// ===== 遥感影像服务类 =====
|
||||
|
||||
export class SatelliteImageService {
|
||||
|
||||
/**
|
||||
* 获取地块的历史影像列表
|
||||
*/
|
||||
static async getFieldImages(
|
||||
fieldId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
source?: string,
|
||||
maxCloudCover: number = 30
|
||||
): Promise<SatelliteImage[]> {
|
||||
// 模拟API调用
|
||||
// 实际应用中这里调用真实的遥感数据API
|
||||
|
||||
const images = this._generateMockImages(startDate, endDate);
|
||||
|
||||
// 过滤条件
|
||||
let filtered = images;
|
||||
|
||||
if (source) {
|
||||
filtered = filtered.filter(img => img.source === source);
|
||||
}
|
||||
|
||||
if (maxCloudCover < 100) {
|
||||
filtered = filtered.filter(img => img.cloudCover <= maxCloudCover);
|
||||
}
|
||||
|
||||
// 按日期排序
|
||||
filtered.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取天地图影像
|
||||
*/
|
||||
static async getTianDiTuImage(
|
||||
lon: number,
|
||||
lat: number,
|
||||
zoom: number = 15
|
||||
): Promise<string> {
|
||||
// 天地图API密钥(需要申请)
|
||||
const tk = 'YOUR_TIANDITU_API_KEY';
|
||||
|
||||
// 构建天地图影像URL
|
||||
const url = `https://t${Math.floor(Math.random() * 8)}.tianditu.gov.cn/img_w/wmts?` +
|
||||
`SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&` +
|
||||
`TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=${zoom}&TILEROW=${lat}&TILECOL=${lon}&tk=${tk}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算NDVI(归一化植被指数)
|
||||
*/
|
||||
static calculateNDVI(nir: number, red: number): number {
|
||||
// NDVI = (NIR - Red) / (NIR + Red)
|
||||
if (nir + red === 0) return 0;
|
||||
return (nir - red) / (nir + red);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算EVI(增强型植被指数)
|
||||
*/
|
||||
static calculateEVI(nir: number, red: number, blue: number): number {
|
||||
// EVI = 2.5 * (NIR - Red) / (NIR + 6 * Red - 7.5 * Blue + 1)
|
||||
const denominator = nir + 6 * red - 7.5 * blue + 1;
|
||||
if (denominator === 0) return 0;
|
||||
return 2.5 * (nir - red) / denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算SAVI(土壤调节植被指数)
|
||||
*/
|
||||
static calculateSAVI(nir: number, red: number, L: number = 0.5): number {
|
||||
// SAVI = ((NIR - Red) / (NIR + Red + L)) * (1 + L)
|
||||
const denominator = nir + red + L;
|
||||
if (denominator === 0) return 0;
|
||||
return ((nir - red) / denominator) * (1 + L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比两个时期的影像
|
||||
*/
|
||||
static compareImages(
|
||||
image1: SatelliteImage,
|
||||
image2: SatelliteImage
|
||||
): ImageComparisonResult {
|
||||
const ndviChange = image2.ndvi - image1.ndvi;
|
||||
const eviChange = image2.evi - image1.evi;
|
||||
|
||||
// 确定变化类型
|
||||
let changeType: 'improvement' | 'decline' | 'stable';
|
||||
if (ndviChange > 0.1) {
|
||||
changeType = 'improvement';
|
||||
} else if (ndviChange < -0.1) {
|
||||
changeType = 'decline';
|
||||
} else {
|
||||
changeType = 'stable';
|
||||
}
|
||||
|
||||
// 生成变化描述
|
||||
const changeDescription = this._generateChangeDescription(ndviChange, eviChange);
|
||||
|
||||
// 生成建议
|
||||
const recommendations = this._generateRecommendations(changeType, image2);
|
||||
|
||||
return {
|
||||
image1,
|
||||
image2,
|
||||
ndviChange,
|
||||
eviChange,
|
||||
changeType,
|
||||
changeDescription,
|
||||
recommendations
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 时序分析
|
||||
*/
|
||||
static analyzeTimeSeries(images: SatelliteImage[]): TimeSeriesAnalysis {
|
||||
if (images.length === 0) {
|
||||
throw new Error('No images provided for analysis');
|
||||
}
|
||||
|
||||
// 按日期排序
|
||||
const sortedImages = [...images].sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
|
||||
const dates = sortedImages.map(img => img.date);
|
||||
const ndviValues = sortedImages.map(img => img.ndvi);
|
||||
const eviValues = sortedImages.map(img => img.evi);
|
||||
|
||||
// 计算趋势
|
||||
const trend = this._calculateTrend(ndviValues);
|
||||
|
||||
// 判断生长阶段
|
||||
const growthStage = this._determineGrowthStage(ndviValues);
|
||||
|
||||
// 计算健康分数(0-100)
|
||||
const avgNDVI = ndviValues.reduce((a, b) => a + b, 0) / ndviValues.length;
|
||||
const healthScore = Math.round(avgNDVI * 100);
|
||||
|
||||
// 生成警报
|
||||
const alerts = this._generateAlerts(sortedImages);
|
||||
|
||||
return {
|
||||
dates,
|
||||
ndviValues,
|
||||
eviValues,
|
||||
trend,
|
||||
growthStage,
|
||||
healthScore,
|
||||
alerts
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取影像质量评价
|
||||
*/
|
||||
static assessImageQuality(image: SatelliteImage): string {
|
||||
if (image.cloudCover < 10 && image.ndvi > 0) {
|
||||
return 'excellent';
|
||||
} else if (image.cloudCover < 20) {
|
||||
return 'good';
|
||||
} else if (image.cloudCover < 30) {
|
||||
return 'fair';
|
||||
} else {
|
||||
return 'poor';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取NDVI等级描述
|
||||
*/
|
||||
static getNDVILabel(ndvi: number): string {
|
||||
if (ndvi > NDVI_THRESHOLDS.excellent) return '长势旺盛';
|
||||
if (ndvi > NDVI_THRESHOLDS.good) return '长势良好';
|
||||
if (ndvi > NDVI_THRESHOLDS.fair) return '长势一般';
|
||||
if (ndvi > NDVI_THRESHOLDS.poor) return '长势较弱';
|
||||
return '无植被';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取NDVI颜色
|
||||
*/
|
||||
static getNDVIColor(ndvi: number): string {
|
||||
if (ndvi > NDVI_THRESHOLDS.excellent) return '#22c55e'; // green-500
|
||||
if (ndvi > NDVI_THRESHOLDS.good) return '#84cc16'; // lime-500
|
||||
if (ndvi > NDVI_THRESHOLDS.fair) return '#eab308'; // yellow-500
|
||||
if (ndvi > NDVI_THRESHOLDS.poor) return '#f97316'; // orange-500
|
||||
return '#ef4444'; // red-500
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载影像
|
||||
*/
|
||||
static async downloadImage(
|
||||
image: SatelliteImage,
|
||||
format: 'tif' | 'jpg' | 'png' = 'jpg'
|
||||
): Promise<void> {
|
||||
// 模拟下载
|
||||
const filename = `${image.source}_${image.date}.${format}`;
|
||||
console.log(`Downloading ${filename}...`);
|
||||
|
||||
// 实际应用中这里应该调用真实的下载API
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.href = image.fullImageUrl;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// ===== 私有辅助方法 =====
|
||||
|
||||
/**
|
||||
* 生成模拟影像数据(用于演示)
|
||||
*/
|
||||
private static _generateMockImages(startDate: string, endDate: string): SatelliteImage[] {
|
||||
const images: SatelliteImage[] = [];
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
// 生成每个月的影像
|
||||
let current = new Date(start);
|
||||
let id = 1;
|
||||
|
||||
while (current <= end) {
|
||||
// Sentinel-2 影像(5天一次)
|
||||
if (id % 3 === 0) {
|
||||
const ndvi = 0.6 + Math.random() * 0.3;
|
||||
images.push({
|
||||
id: `sentinel-${id}`,
|
||||
date: current.toISOString().split('T')[0],
|
||||
source: 'Sentinel-2',
|
||||
cloudCover: Math.random() * 40,
|
||||
resolution: 10,
|
||||
ndvi: ndvi,
|
||||
evi: ndvi * 0.85,
|
||||
savi: ndvi * 0.75,
|
||||
lai: ndvi * 5,
|
||||
thumbnail: `https://placeholder.com/sentinel-${id}`,
|
||||
fullImageUrl: `https://api.sentinel.com/image/${id}`,
|
||||
season: this._getSeason(current),
|
||||
quality: 'good',
|
||||
bands: {
|
||||
red: 'B4',
|
||||
green: 'B3',
|
||||
blue: 'B2',
|
||||
nir: 'B8'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Landsat-8 影像(16天一次)
|
||||
if (id % 5 === 0) {
|
||||
const ndvi = 0.55 + Math.random() * 0.35;
|
||||
images.push({
|
||||
id: `landsat-${id}`,
|
||||
date: current.toISOString().split('T')[0],
|
||||
source: 'Landsat-8',
|
||||
cloudCover: Math.random() * 35,
|
||||
resolution: 30,
|
||||
ndvi: ndvi,
|
||||
evi: ndvi * 0.88,
|
||||
savi: ndvi * 0.78,
|
||||
lai: ndvi * 4.8,
|
||||
thumbnail: `https://placeholder.com/landsat-${id}`,
|
||||
fullImageUrl: `https://api.landsat.com/image/${id}`,
|
||||
season: this._getSeason(current),
|
||||
quality: 'good',
|
||||
bands: {
|
||||
red: 'B4',
|
||||
green: 'B3',
|
||||
blue: 'B2',
|
||||
nir: 'B5'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 5);
|
||||
id++;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取季节
|
||||
*/
|
||||
private static _getSeason(date: Date): string {
|
||||
const month = date.getMonth() + 1;
|
||||
if (month >= 3 && month <= 5) return '春季';
|
||||
if (month >= 6 && month <= 8) return '夏季';
|
||||
if (month >= 9 && month <= 11) return '秋季';
|
||||
return '冬季';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成变化描述
|
||||
*/
|
||||
private static _generateChangeDescription(ndviChange: number, eviChange: number): string {
|
||||
if (ndviChange > 0.2) {
|
||||
return '植被覆盖度显著增加,作物长势明显改善';
|
||||
} else if (ndviChange > 0.1) {
|
||||
return '植被覆盖度有所增加,作物长势良好';
|
||||
} else if (ndviChange < -0.2) {
|
||||
return '植被覆盖度显著下降,需要关注作物健康状况';
|
||||
} else if (ndviChange < -0.1) {
|
||||
return '植被覆盖度有所下降,建议加强管理';
|
||||
} else {
|
||||
return '植被覆盖度基本稳定,作物生长正常';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成建议
|
||||
*/
|
||||
private static _generateRecommendations(
|
||||
changeType: 'improvement' | 'decline' | 'stable',
|
||||
currentImage: SatelliteImage
|
||||
): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (changeType === 'decline') {
|
||||
recommendations.push('建议增加灌溉频次,确保水分供应');
|
||||
recommendations.push('检查是否存在病虫害,及时防治');
|
||||
recommendations.push('适当追施氮肥,促进作物生长');
|
||||
} else if (changeType === 'improvement') {
|
||||
recommendations.push('继续保持当前管理措施');
|
||||
recommendations.push('适时进行田间观察,记录生长情况');
|
||||
recommendations.push('注意防范极端天气影响');
|
||||
} else {
|
||||
recommendations.push('维持现有灌溉和施肥计划');
|
||||
recommendations.push('定期监测作物生长状态');
|
||||
}
|
||||
|
||||
// 根据NDVI值添加额外建议
|
||||
if (currentImage.ndvi < 0.4) {
|
||||
recommendations.push('⚠️ NDVI值偏低,建议进行土壤检测');
|
||||
}
|
||||
|
||||
// 根据云量添加建议
|
||||
if (currentImage.cloudCover > 30) {
|
||||
recommendations.push('当前云量较高,建议选择晴天影像进行分析');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算趋势
|
||||
*/
|
||||
private static _calculateTrend(values: number[]): 'increasing' | 'decreasing' | 'fluctuating' | 'stable' {
|
||||
if (values.length < 2) return 'stable';
|
||||
|
||||
// 简单线性回归
|
||||
const n = values.length;
|
||||
const sumX = (n * (n - 1)) / 2;
|
||||
const sumY = values.reduce((a, b) => a + b, 0);
|
||||
const sumXY = values.reduce((sum, y, x) => sum + x * y, 0);
|
||||
const sumX2 = (n * (n - 1) * (2 * n - 1)) / 6;
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||
|
||||
// 计算波动性
|
||||
const mean = sumY / n;
|
||||
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / n;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
if (Math.abs(slope) < 0.01 && stdDev < 0.05) {
|
||||
return 'stable';
|
||||
} else if (stdDev > 0.15) {
|
||||
return 'fluctuating';
|
||||
} else if (slope > 0.01) {
|
||||
return 'increasing';
|
||||
} else if (slope < -0.01) {
|
||||
return 'decreasing';
|
||||
}
|
||||
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断生长阶段
|
||||
*/
|
||||
private static _determineGrowthStage(ndviValues: number[]): string {
|
||||
if (ndviValues.length === 0) return '未知';
|
||||
|
||||
const latestNDVI = ndviValues[ndviValues.length - 1];
|
||||
const avgNDVI = ndviValues.reduce((a, b) => a + b, 0) / ndviValues.length;
|
||||
|
||||
if (latestNDVI < 0.3) {
|
||||
return '苗期';
|
||||
} else if (latestNDVI < 0.5) {
|
||||
return '生长期';
|
||||
} else if (latestNDVI < 0.7) {
|
||||
return '旺长期';
|
||||
} else if (latestNDVI >= 0.7 && latestNDVI < 0.85) {
|
||||
return '成熟前期';
|
||||
} else {
|
||||
return '成熟期';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成警报
|
||||
*/
|
||||
private static _generateAlerts(images: SatelliteImage[]): string[] {
|
||||
const alerts: string[] = [];
|
||||
|
||||
if (images.length < 2) return alerts;
|
||||
|
||||
// 检查最近的NDVI变化
|
||||
const recent = images.slice(-3);
|
||||
const ndviValues = recent.map(img => img.ndvi);
|
||||
|
||||
// NDVI急剧下降
|
||||
if (ndviValues.length >= 2) {
|
||||
const change = ndviValues[ndviValues.length - 1] - ndviValues[0];
|
||||
if (change < -0.2) {
|
||||
alerts.push('⚠️ 警告:近期植被指数急剧下降,请检查作物健康状况');
|
||||
}
|
||||
}
|
||||
|
||||
// 云量过高
|
||||
const highCloudImages = recent.filter(img => img.cloudCover > 40);
|
||||
if (highCloudImages.length >= 2) {
|
||||
alerts.push('☁️ 提示:近期云量较高,建议选择晴天影像进行分析');
|
||||
}
|
||||
|
||||
// NDVI持续低迷
|
||||
const lowNDVIImages = recent.filter(img => img.ndvi < 0.4);
|
||||
if (lowNDVIImages.length >= 2) {
|
||||
alerts.push('⚠️ 注意:植被指数持续偏低,建议加强田间管理');
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 导出工具函数 =====
|
||||
|
||||
/**
|
||||
* 获取云量颜色类
|
||||
*/
|
||||
export function getCloudCoverColorClass(cloudCover: number): string {
|
||||
if (cloudCover < 10) return 'text-green-600 bg-green-100';
|
||||
if (cloudCover < 20) return 'text-yellow-600 bg-yellow-100';
|
||||
if (cloudCover < 30) return 'text-orange-600 bg-orange-100';
|
||||
return 'text-red-600 bg-red-100';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatImageDate(date: string): string {
|
||||
const d = new Date(date);
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源图标
|
||||
*/
|
||||
export function getDataSourceIcon(source: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
'Sentinel-2': '🛰️',
|
||||
'Landsat-8': '🛰️',
|
||||
'Landsat-9': '🛰️',
|
||||
'天地图': '🗺️',
|
||||
'GF-1': '🛰️',
|
||||
'GF-2': '🛰️'
|
||||
};
|
||||
return icons[source] || '📡';
|
||||
}
|
||||
937
src/lib/spatialDataService.ts
Normal file
937
src/lib/spatialDataService.ts
Normal file
@@ -0,0 +1,937 @@
|
||||
/**
|
||||
* 空间数据服务API
|
||||
* 提供PostGIS风格的空间查询、几何计算和数据导出功能
|
||||
*/
|
||||
|
||||
// ===== 类型定义 =====
|
||||
|
||||
export interface Point {
|
||||
lat: number;
|
||||
lng: number;
|
||||
alt?: number; // 海拔高度
|
||||
}
|
||||
|
||||
export interface Polygon {
|
||||
points: Point[];
|
||||
holes?: Point[][]; // 多边形的孔洞
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
geometry: Polygon;
|
||||
properties?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SpatialQueryResult<T = any> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
executionTime: number; // 毫秒
|
||||
}
|
||||
|
||||
// ===== 常量定义 =====
|
||||
|
||||
// WGS-84椭球参数
|
||||
const WGS84_A = 6378137.0; // 长半轴(米)
|
||||
const WGS84_B = 6356752.314245; // 短半轴(米)
|
||||
const WGS84_F = 1 / 298.257223563; // 扁率
|
||||
|
||||
// 1亩 = 666.67平方米
|
||||
const MU_TO_SQUARE_METERS = 666.67;
|
||||
|
||||
// ===== 1. 空间查询API =====
|
||||
|
||||
/**
|
||||
* 点面查询:判断点是否在多边形内
|
||||
* 使用射线法(Ray Casting Algorithm)
|
||||
*/
|
||||
export class SpatialQuery {
|
||||
/**
|
||||
* 点在多边形内查询
|
||||
* @param point 查询点
|
||||
* @param fields 地块列表
|
||||
* @returns 包含该点的地块列表
|
||||
*/
|
||||
static pointInPolygon(point: Point, fields: Field[]): SpatialQueryResult<{
|
||||
matched: boolean;
|
||||
fields: Array<{
|
||||
field: Field;
|
||||
distanceToBorder: number; // 到边界的最短距离(米)
|
||||
}>;
|
||||
}> {
|
||||
const startTime = performance.now();
|
||||
const results: Array<{ field: Field; distanceToBorder: number }> = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (this._isPointInPolygon(point, field.geometry.points)) {
|
||||
const distance = this._pointToPolygonDistance(point, field.geometry.points);
|
||||
results.push({ field, distanceToBorder: distance });
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = performance.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
matched: results.length > 0,
|
||||
fields: results,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 多边形相交查询
|
||||
* @param sourceField 源地块
|
||||
* @param targetFields 目标地块列表
|
||||
* @returns 与源地块相交的地块列表
|
||||
*/
|
||||
static polygonIntersect(
|
||||
sourceField: Field,
|
||||
targetFields: Field[]
|
||||
): SpatialQueryResult<{
|
||||
intersections: Array<{
|
||||
field: Field;
|
||||
intersectArea: number; // 相交面积(亩)
|
||||
intersectRatio: number; // 相交比例(%)
|
||||
intersectGeometry: Polygon; // 相交区域几何
|
||||
}>;
|
||||
}> {
|
||||
const startTime = performance.now();
|
||||
const intersections: Array<{
|
||||
field: Field;
|
||||
intersectArea: number;
|
||||
intersectRatio: number;
|
||||
intersectGeometry: Polygon;
|
||||
}> = [];
|
||||
|
||||
const sourceArea = GeometryCalculator.calculateArea(sourceField.geometry);
|
||||
|
||||
for (const targetField of targetFields) {
|
||||
if (targetField.id === sourceField.id) continue;
|
||||
|
||||
if (this._polygonsIntersect(sourceField.geometry.points, targetField.geometry.points)) {
|
||||
const intersectGeometry = this._calculateIntersection(
|
||||
sourceField.geometry,
|
||||
targetField.geometry
|
||||
);
|
||||
const intersectArea = GeometryCalculator.calculateArea(intersectGeometry);
|
||||
const intersectRatio = (intersectArea / sourceArea) * 100;
|
||||
|
||||
intersections.push({
|
||||
field: targetField,
|
||||
intersectArea,
|
||||
intersectRatio,
|
||||
intersectGeometry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = performance.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { intersections },
|
||||
timestamp: new Date().toISOString(),
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相邻地块查询
|
||||
* @param sourceField 源地块
|
||||
* @param targetFields 目标地块列表
|
||||
* @returns 与源地块相邻的地块列表
|
||||
*/
|
||||
static adjacentPolygons(
|
||||
sourceField: Field,
|
||||
targetFields: Field[]
|
||||
): SpatialQueryResult<{
|
||||
adjacentFields: Array<{
|
||||
field: Field;
|
||||
sharedBorderLength: number; // 共享边界长度(米)
|
||||
sharedBorderPoints: Point[]; // 共享边界点
|
||||
}>;
|
||||
}> {
|
||||
const startTime = performance.now();
|
||||
const adjacentFields: Array<{
|
||||
field: Field;
|
||||
sharedBorderLength: number;
|
||||
sharedBorderPoints: Point[];
|
||||
}> = [];
|
||||
|
||||
for (const targetField of targetFields) {
|
||||
if (targetField.id === sourceField.id) continue;
|
||||
|
||||
const { isAdjacent, sharedBorder } = this._checkAdjacency(
|
||||
sourceField.geometry.points,
|
||||
targetField.geometry.points
|
||||
);
|
||||
|
||||
if (isAdjacent && sharedBorder.length > 0) {
|
||||
const sharedBorderLength = GeometryCalculator.calculatePerimeter({
|
||||
points: sharedBorder,
|
||||
});
|
||||
|
||||
adjacentFields.push({
|
||||
field: targetField,
|
||||
sharedBorderLength,
|
||||
sharedBorderPoints: sharedBorder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = performance.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { adjacentFields },
|
||||
timestamp: new Date().toISOString(),
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓冲区分析
|
||||
* @param sourceField 源地块
|
||||
* @param bufferDistance 缓冲区距离(米)
|
||||
* @param targetFields 目标地块列表
|
||||
* @returns 缓冲区内的地块列表
|
||||
*/
|
||||
static bufferAnalysis(
|
||||
sourceField: Field,
|
||||
bufferDistance: number,
|
||||
targetFields: Field[]
|
||||
): SpatialQueryResult<{
|
||||
bufferGeometry: Polygon;
|
||||
bufferArea: number; // 缓冲区面积(亩)
|
||||
fieldsInBuffer: Array<{
|
||||
field: Field;
|
||||
distance: number; // 最短距离(米)
|
||||
overlap: boolean; // 是否重叠
|
||||
}>;
|
||||
}> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// 生成缓冲区几何
|
||||
const bufferGeometry = this._createBuffer(sourceField.geometry, bufferDistance);
|
||||
const bufferArea = GeometryCalculator.calculateArea(bufferGeometry);
|
||||
|
||||
const fieldsInBuffer: Array<{
|
||||
field: Field;
|
||||
distance: number;
|
||||
overlap: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const targetField of targetFields) {
|
||||
if (targetField.id === sourceField.id) continue;
|
||||
|
||||
const distance = this._polygonToPolygonDistance(
|
||||
sourceField.geometry.points,
|
||||
targetField.geometry.points
|
||||
);
|
||||
|
||||
if (distance <= bufferDistance) {
|
||||
const overlap = this._polygonsIntersect(
|
||||
bufferGeometry.points,
|
||||
targetField.geometry.points
|
||||
);
|
||||
fieldsInBuffer.push({
|
||||
field: targetField,
|
||||
distance,
|
||||
overlap,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = performance.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
bufferGeometry,
|
||||
bufferArea,
|
||||
fieldsInBuffer,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 私有辅助方法 =====
|
||||
|
||||
/**
|
||||
* 射线法判断点是否在多边形内
|
||||
*/
|
||||
private static _isPointInPolygon(point: Point, polygon: Point[]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].lng,
|
||||
yi = polygon[i].lat;
|
||||
const xj = polygon[j].lng,
|
||||
yj = polygon[j].lat;
|
||||
|
||||
const intersect =
|
||||
yi > point.lat !== yj > point.lat &&
|
||||
point.lng < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算点到多边形边界的最短距离
|
||||
*/
|
||||
private static _pointToPolygonDistance(point: Point, polygon: Point[]): number {
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
const p1 = polygon[i];
|
||||
const p2 = polygon[(i + 1) % polygon.length];
|
||||
const distance = this._pointToSegmentDistance(point, p1, p2);
|
||||
minDistance = Math.min(minDistance, distance);
|
||||
}
|
||||
|
||||
return minDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算点到线段的距离
|
||||
*/
|
||||
private static _pointToSegmentDistance(point: Point, p1: Point, p2: Point): number {
|
||||
const dx = p2.lng - p1.lng;
|
||||
const dy = p2.lat - p1.lat;
|
||||
|
||||
if (dx === 0 && dy === 0) {
|
||||
return GeometryCalculator.haversineDistance(point, p1);
|
||||
}
|
||||
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
((point.lng - p1.lng) * dx + (point.lat - p1.lat) * dy) / (dx * dx + dy * dy)
|
||||
)
|
||||
);
|
||||
|
||||
const nearestPoint: Point = {
|
||||
lat: p1.lat + t * dy,
|
||||
lng: p1.lng + t * dx,
|
||||
};
|
||||
|
||||
return GeometryCalculator.haversineDistance(point, nearestPoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个多边形是否相交
|
||||
*/
|
||||
private static _polygonsIntersect(poly1: Point[], poly2: Point[]): boolean {
|
||||
// 检查是否有顶点在另一个多边形内
|
||||
for (const point of poly1) {
|
||||
if (this._isPointInPolygon(point, poly2)) return true;
|
||||
}
|
||||
for (const point of poly2) {
|
||||
if (this._isPointInPolygon(point, poly1)) return true;
|
||||
}
|
||||
|
||||
// 检查边是否相交
|
||||
for (let i = 0; i < poly1.length; i++) {
|
||||
const p1 = poly1[i];
|
||||
const p2 = poly1[(i + 1) % poly1.length];
|
||||
|
||||
for (let j = 0; j < poly2.length; j++) {
|
||||
const p3 = poly2[j];
|
||||
const p4 = poly2[(j + 1) % poly2.length];
|
||||
|
||||
if (this._segmentsIntersect(p1, p2, p3, p4)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两条线段是否相交
|
||||
*/
|
||||
private static _segmentsIntersect(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
|
||||
const ccw = (A: Point, B: Point, C: Point) => {
|
||||
return (C.lat - A.lat) * (B.lng - A.lng) > (B.lat - A.lat) * (C.lng - A.lng);
|
||||
};
|
||||
|
||||
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个多边形的相交区域(简化实现)
|
||||
*/
|
||||
private static _calculateIntersection(poly1: Polygon, poly2: Polygon): Polygon {
|
||||
// 这里使用简化算法,实际应用中应使用Sutherland-Hodgman算法
|
||||
const intersectPoints: Point[] = [];
|
||||
|
||||
// 收集在两个多边形内的点
|
||||
for (const point of poly1.points) {
|
||||
if (this._isPointInPolygon(point, poly2.points)) {
|
||||
intersectPoints.push(point);
|
||||
}
|
||||
}
|
||||
|
||||
for (const point of poly2.points) {
|
||||
if (this._isPointInPolygon(point, poly1.points)) {
|
||||
intersectPoints.push(point);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有交点,返回空多边形
|
||||
if (intersectPoints.length === 0) {
|
||||
return { points: [] };
|
||||
}
|
||||
|
||||
// 计算凸包作为相交区域的近似
|
||||
return { points: this._convexHull(intersectPoints) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算凸包(Graham扫描算法)
|
||||
*/
|
||||
private static _convexHull(points: Point[]): Point[] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
// 找到最下最左的点
|
||||
let start = points[0];
|
||||
points.forEach((p) => {
|
||||
if (p.lat < start.lat || (p.lat === start.lat && p.lng < start.lng)) {
|
||||
start = p;
|
||||
}
|
||||
});
|
||||
|
||||
// 按极角排序
|
||||
const sorted = points
|
||||
.filter((p) => p !== start)
|
||||
.sort((a, b) => {
|
||||
const angleA = Math.atan2(a.lat - start.lat, a.lng - start.lng);
|
||||
const angleB = Math.atan2(b.lat - start.lat, b.lng - start.lng);
|
||||
return angleA - angleB;
|
||||
});
|
||||
|
||||
const hull: Point[] = [start];
|
||||
|
||||
for (const point of sorted) {
|
||||
while (hull.length >= 2) {
|
||||
const p2 = hull[hull.length - 1];
|
||||
const p1 = hull[hull.length - 2];
|
||||
const cross =
|
||||
(p2.lng - p1.lng) * (point.lat - p1.lat) -
|
||||
(p2.lat - p1.lat) * (point.lng - p1.lng);
|
||||
if (cross <= 0) {
|
||||
hull.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
hull.push(point);
|
||||
}
|
||||
|
||||
return hull;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查两个多边形是否相邻
|
||||
*/
|
||||
private static _checkAdjacency(
|
||||
poly1: Point[],
|
||||
poly2: Point[]
|
||||
): { isAdjacent: boolean; sharedBorder: Point[] } {
|
||||
const sharedBorder: Point[] = [];
|
||||
const tolerance = 0.00001; // 约1米的容差
|
||||
|
||||
for (let i = 0; i < poly1.length; i++) {
|
||||
const p1 = poly1[i];
|
||||
const p2 = poly1[(i + 1) % poly1.length];
|
||||
|
||||
for (let j = 0; j < poly2.length; j++) {
|
||||
const p3 = poly2[j];
|
||||
const p4 = poly2[(j + 1) % poly2.length];
|
||||
|
||||
// 检查边是否重合
|
||||
if (this._edgesOverlap(p1, p2, p3, p4, tolerance)) {
|
||||
if (sharedBorder.length === 0 || !this._pointsEqual(sharedBorder[sharedBorder.length - 1], p1, tolerance)) {
|
||||
sharedBorder.push(p1);
|
||||
}
|
||||
sharedBorder.push(p2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAdjacent: sharedBorder.length >= 2,
|
||||
sharedBorder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两条边是否重合
|
||||
*/
|
||||
private static _edgesOverlap(
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point,
|
||||
p4: Point,
|
||||
tolerance: number
|
||||
): boolean {
|
||||
return (
|
||||
(this._pointsEqual(p1, p3, tolerance) && this._pointsEqual(p2, p4, tolerance)) ||
|
||||
(this._pointsEqual(p1, p4, tolerance) && this._pointsEqual(p2, p3, tolerance))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个点是否相等(在容差范围内)
|
||||
*/
|
||||
private static _pointsEqual(p1: Point, p2: Point, tolerance: number): boolean {
|
||||
return (
|
||||
Math.abs(p1.lat - p2.lat) < tolerance && Math.abs(p1.lng - p2.lng) < tolerance
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算多边形到多边形的最短距离
|
||||
*/
|
||||
private static _polygonToPolygonDistance(poly1: Point[], poly2: Point[]): number {
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const point of poly1) {
|
||||
const distance = this._pointToPolygonDistance(point, poly2);
|
||||
minDistance = Math.min(minDistance, distance);
|
||||
}
|
||||
|
||||
for (const point of poly2) {
|
||||
const distance = this._pointToPolygonDistance(point, poly1);
|
||||
minDistance = Math.min(minDistance, distance);
|
||||
}
|
||||
|
||||
return minDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建缓冲区(简化实现)
|
||||
*/
|
||||
private static _createBuffer(geometry: Polygon, distance: number): Polygon {
|
||||
const bufferPoints: Point[] = [];
|
||||
const points = geometry.points;
|
||||
|
||||
// 简化算法:对每个顶点,在法线方向上偏移distance距离
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const prev = points[i === 0 ? points.length - 1 : i - 1];
|
||||
const curr = points[i];
|
||||
const next = points[(i + 1) % points.length];
|
||||
|
||||
// 计算法向量
|
||||
const v1 = { lat: curr.lat - prev.lat, lng: curr.lng - prev.lng };
|
||||
const v2 = { lat: next.lat - curr.lat, lng: next.lng - curr.lng };
|
||||
|
||||
// 计算平均法向量
|
||||
const normal = {
|
||||
lat: -(v1.lng + v2.lng),
|
||||
lng: v1.lat + v2.lat,
|
||||
};
|
||||
|
||||
// 归一化
|
||||
const length = Math.sqrt(normal.lat * normal.lat + normal.lng * normal.lng);
|
||||
if (length > 0) {
|
||||
normal.lat /= length;
|
||||
normal.lng /= length;
|
||||
}
|
||||
|
||||
// 偏移顶点(简化:使用度数偏移,实际应转换为米)
|
||||
const offsetDegrees = distance / 111320; // 约111.32km每度
|
||||
bufferPoints.push({
|
||||
lat: curr.lat + normal.lat * offsetDegrees,
|
||||
lng: curr.lng + normal.lng * offsetDegrees,
|
||||
});
|
||||
}
|
||||
|
||||
return { points: bufferPoints };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 2. 几何计算API =====
|
||||
|
||||
export class GeometryCalculator {
|
||||
/**
|
||||
* 计算多边形精确面积(考虑地球曲率)
|
||||
* 使用球面三角形面积公式
|
||||
*/
|
||||
static calculateArea(geometry: Polygon): number {
|
||||
const points = geometry.points;
|
||||
if (points.length < 3) return 0;
|
||||
|
||||
// 将多边形分解为三角形,计算球面三角形面积之和
|
||||
let totalArea = 0;
|
||||
const origin = points[0];
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const area = this._sphericalTriangleArea(origin, points[i], points[i + 1]);
|
||||
totalArea += area;
|
||||
}
|
||||
|
||||
// 转换为亩
|
||||
return totalArea / MU_TO_SQUARE_METERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算球面三角形面积(L'Huilier定理)
|
||||
*/
|
||||
private static _sphericalTriangleArea(p1: Point, p2: Point, p3: Point): number {
|
||||
const R = WGS84_A; // 使用WGS-84长半轴
|
||||
|
||||
// 转换为弧度
|
||||
const lat1 = this._toRadians(p1.lat);
|
||||
const lng1 = this._toRadians(p1.lng);
|
||||
const lat2 = this._toRadians(p2.lat);
|
||||
const lng2 = this._toRadians(p2.lng);
|
||||
const lat3 = this._toRadians(p3.lat);
|
||||
const lng3 = this._toRadians(p3.lng);
|
||||
|
||||
// 计算边长(球面距离)
|
||||
const a = this._sphericalDistance(lat2, lng2, lat3, lng3, R);
|
||||
const b = this._sphericalDistance(lat3, lng3, lat1, lng1, R);
|
||||
const c = this._sphericalDistance(lat1, lng1, lat2, lng2, R);
|
||||
|
||||
// 半周长
|
||||
const s = (a + b + c) / 2;
|
||||
|
||||
// L'Huilier定理
|
||||
const E = 4 * Math.atan(Math.sqrt(Math.tan(s / (2 * R)) * Math.tan((s - a) / (2 * R)) * Math.tan((s - b) / (2 * R)) * Math.tan((s - c) / (2 * R))));
|
||||
|
||||
// 面积 = R² * E
|
||||
return R * R * E;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算球面距离
|
||||
*/
|
||||
private static _sphericalDistance(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
radius: number
|
||||
): number {
|
||||
const dLat = lat2 - lat1;
|
||||
const dLng = lng2 - lng1;
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return radius * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算多边形周长(考虑地球曲率)
|
||||
*/
|
||||
static calculatePerimeter(geometry: Polygon): number {
|
||||
const points = geometry.points;
|
||||
if (points.length < 2) return 0;
|
||||
|
||||
let perimeter = 0;
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p1 = points[i];
|
||||
const p2 = points[(i + 1) % points.length];
|
||||
perimeter += this.haversineDistance(p1, p2);
|
||||
}
|
||||
|
||||
return perimeter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haversine公式计算两点间距离(米)
|
||||
*/
|
||||
static haversineDistance(p1: Point, p2: Point): number {
|
||||
const R = WGS84_A;
|
||||
|
||||
const lat1 = this._toRadians(p1.lat);
|
||||
const lng1 = this._toRadians(p1.lng);
|
||||
const lat2 = this._toRadians(p2.lat);
|
||||
const lng2 = this._toRadians(p2.lng);
|
||||
|
||||
return this._sphericalDistance(lat1, lng1, lat2, lng2, R);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算多边形中心点(几何中心)
|
||||
*/
|
||||
static calculateCentroid(geometry: Polygon): Point {
|
||||
const points = geometry.points;
|
||||
if (points.length === 0) return { lat: 0, lng: 0 };
|
||||
|
||||
let sumLat = 0;
|
||||
let sumLng = 0;
|
||||
let sumArea = 0;
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p1 = points[i];
|
||||
const p2 = points[(i + 1) % points.length];
|
||||
|
||||
const cross = p1.lng * p2.lat - p2.lng * p1.lat;
|
||||
sumArea += cross;
|
||||
sumLat += (p1.lat + p2.lat) * cross;
|
||||
sumLng += (p1.lng + p2.lng) * cross;
|
||||
}
|
||||
|
||||
sumArea /= 2;
|
||||
|
||||
if (Math.abs(sumArea) < 1e-10) {
|
||||
// 如果面积接近0,使用简单平均
|
||||
const avgLat = points.reduce((sum, p) => sum + p.lat, 0) / points.length;
|
||||
const avgLng = points.reduce((sum, p) => sum + p.lng, 0) / points.length;
|
||||
return { lat: avgLat, lng: avgLng };
|
||||
}
|
||||
|
||||
const centroidLat = sumLat / (6 * sumArea);
|
||||
const centroidLng = sumLng / (6 * sumArea);
|
||||
|
||||
return { lat: centroidLat, lng: centroidLng };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算包围盒(Bounding Box)
|
||||
*/
|
||||
static calculateBoundingBox(geometry: Polygon): {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
center: Point;
|
||||
} {
|
||||
const points = geometry.points;
|
||||
if (points.length === 0) {
|
||||
return {
|
||||
minLat: 0,
|
||||
maxLat: 0,
|
||||
minLng: 0,
|
||||
maxLng: 0,
|
||||
center: { lat: 0, lng: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
let minLat = points[0].lat;
|
||||
let maxLat = points[0].lat;
|
||||
let minLng = points[0].lng;
|
||||
let maxLng = points[0].lng;
|
||||
|
||||
for (const point of points) {
|
||||
minLat = Math.min(minLat, point.lat);
|
||||
maxLat = Math.max(maxLat, point.lat);
|
||||
minLng = Math.min(minLng, point.lng);
|
||||
maxLng = Math.max(maxLng, point.lng);
|
||||
}
|
||||
|
||||
return {
|
||||
minLat,
|
||||
maxLat,
|
||||
minLng,
|
||||
maxLng,
|
||||
center: {
|
||||
lat: (minLat + maxLat) / 2,
|
||||
lng: (minLng + maxLng) / 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 角度转弧度
|
||||
*/
|
||||
private static _toRadians(degrees: number): number {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* 弧度转角度
|
||||
*/
|
||||
private static _toDegrees(radians: number): number {
|
||||
return (radians * 180) / Math.PI;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 3. 数据导出API =====
|
||||
|
||||
export class DataExporter {
|
||||
/**
|
||||
* 导出为GeoJSON格式
|
||||
*/
|
||||
static exportToGeoJSON(fields: Field[]): string {
|
||||
const features = fields.map((field) => ({
|
||||
type: 'Feature',
|
||||
id: field.id,
|
||||
properties: {
|
||||
name: field.name,
|
||||
code: field.code,
|
||||
area: GeometryCalculator.calculateArea(field.geometry),
|
||||
perimeter: GeometryCalculator.calculatePerimeter(field.geometry),
|
||||
centroid: GeometryCalculator.calculateCentroid(field.geometry),
|
||||
...field.properties,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [field.geometry.points.map((p) => [p.lng, p.lat])],
|
||||
},
|
||||
}));
|
||||
|
||||
const geoJSON = {
|
||||
type: 'FeatureCollection',
|
||||
crs: {
|
||||
type: 'name',
|
||||
properties: {
|
||||
name: 'EPSG:4326', // WGS-84
|
||||
},
|
||||
},
|
||||
features,
|
||||
};
|
||||
|
||||
return JSON.stringify(geoJSON, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为KML格式
|
||||
*/
|
||||
static exportToKML(fields: Field[]): string {
|
||||
const placemarks = fields
|
||||
.map((field) => {
|
||||
const coords = field.geometry.points.map((p) => `${p.lng},${p.lat},0`).join(' ');
|
||||
|
||||
return `
|
||||
<Placemark>
|
||||
<name>${field.name}</name>
|
||||
<description>
|
||||
编号: ${field.code}
|
||||
面积: ${GeometryCalculator.calculateArea(field.geometry).toFixed(2)} 亩
|
||||
周长: ${GeometryCalculator.calculatePerimeter(field.geometry).toFixed(0)} 米
|
||||
</description>
|
||||
<Polygon>
|
||||
<outerBoundaryIs>
|
||||
<LinearRing>
|
||||
<coordinates>${coords}</coordinates>
|
||||
</LinearRing>
|
||||
</outerBoundaryIs>
|
||||
</Polygon>
|
||||
</Placemark>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<name>地块数据</name>
|
||||
<description>智慧农业生产管理系统地块导出</description>
|
||||
${placemarks}
|
||||
</Document>
|
||||
</kml>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为Shapefile格式(WKT格式)
|
||||
*/
|
||||
static exportToWKT(field: Field): string {
|
||||
const coords = field.geometry.points.map((p) => `${p.lng} ${p.lat}`).join(', ');
|
||||
return `POLYGON((${coords}))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为CSV格式
|
||||
*/
|
||||
static exportToCSV(fields: Field[]): string {
|
||||
const headers = ['ID', '名称', '编号', '面积(亩)', '周长(米)', '中心点纬度', '中心点经度'];
|
||||
const rows = fields.map((field) => {
|
||||
const centroid = GeometryCalculator.calculateCentroid(field.geometry);
|
||||
return [
|
||||
field.id,
|
||||
field.name,
|
||||
field.code,
|
||||
GeometryCalculator.calculateArea(field.geometry).toFixed(2),
|
||||
GeometryCalculator.calculatePerimeter(field.geometry).toFixed(0),
|
||||
centroid.lat.toFixed(6),
|
||||
centroid.lng.toFixed(6),
|
||||
];
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map((row) => row.join(',')),
|
||||
].join('\n');
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
static downloadFile(content: string, filename: string, mimeType: string): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 4. 空间索引(用于性能优化) =====
|
||||
|
||||
export class SpatialIndex {
|
||||
private rtree: Map<string, { bbox: any; field: Field }>;
|
||||
|
||||
constructor() {
|
||||
this.rtree = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入地块
|
||||
*/
|
||||
insert(field: Field): void {
|
||||
const bbox = GeometryCalculator.calculateBoundingBox(field.geometry);
|
||||
this.rtree.set(field.id, { bbox, field });
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速查询可能相交的地块
|
||||
*/
|
||||
query(bbox: {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}): Field[] {
|
||||
const results: Field[] = [];
|
||||
|
||||
for (const [_, item] of this.rtree) {
|
||||
if (this._bboxesIntersect(bbox, item.bbox)) {
|
||||
results.push(item.field);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断两个包围盒是否相交
|
||||
*/
|
||||
private _bboxesIntersect(
|
||||
bbox1: { minLat: number; maxLat: number; minLng: number; maxLng: number },
|
||||
bbox2: { minLat: number; maxLat: number; minLng: number; maxLng: number }
|
||||
): boolean {
|
||||
return !(
|
||||
bbox1.maxLat < bbox2.minLat ||
|
||||
bbox1.minLat > bbox2.maxLat ||
|
||||
bbox1.maxLng < bbox2.minLng ||
|
||||
bbox1.minLng > bbox2.maxLng
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/lib/usePagination.ts
Normal file
78
src/lib/usePagination.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
export interface PaginationConfig {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UsePaginationReturn<T> {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
paginatedData: T[];
|
||||
goToPage: (page: number) => void;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
setPageSize: (size: number) => void;
|
||||
canPreviousPage: boolean;
|
||||
canNextPage: boolean;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export function usePagination<T>(
|
||||
data: T[],
|
||||
initialPageSize: number = 10
|
||||
): UsePaginationReturn<T> {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(initialPageSize);
|
||||
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return data.slice(startIndex, endIndex);
|
||||
}, [data, currentPage, pageSize]);
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
const pageNumber = Math.max(1, Math.min(page, totalPages));
|
||||
setCurrentPage(pageNumber);
|
||||
};
|
||||
|
||||
const nextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const previousPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPageSize = (size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
};
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize + 1;
|
||||
const endIndex = Math.min(currentPage * pageSize, data.length);
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
paginatedData,
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize: handleSetPageSize,
|
||||
canPreviousPage: currentPage > 1,
|
||||
canNextPage: currentPage < totalPages,
|
||||
startIndex,
|
||||
endIndex,
|
||||
};
|
||||
}
|
||||
230
src/lib/workHourTracker.ts
Normal file
230
src/lib/workHourTracker.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 工时跟踪器
|
||||
* 用于自动从任务执行中记录和同步工时
|
||||
*/
|
||||
|
||||
interface WorkHourRecord {
|
||||
id: string;
|
||||
staffId: string;
|
||||
staffName: string;
|
||||
taskId?: string;
|
||||
taskName?: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
totalHours: number;
|
||||
workType: string;
|
||||
fieldName: string;
|
||||
area: number;
|
||||
notes: string;
|
||||
status: '待审核' | '已审核' | '已驳回';
|
||||
recordType: '自动记录' | '手动录入';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务完成时自动创建工时记录
|
||||
* 在任务状态更新为"已完成"时调用此函数
|
||||
*/
|
||||
export function createWorkHourFromTask(task: {
|
||||
id: string;
|
||||
taskName: string;
|
||||
executor: string;
|
||||
executorId?: string;
|
||||
actualStartTime: string;
|
||||
actualEndTime: string;
|
||||
operationType?: string;
|
||||
fieldName?: string;
|
||||
area?: number;
|
||||
}): WorkHourRecord | null {
|
||||
try {
|
||||
// 验证必要字段
|
||||
if (!task.actualStartTime || !task.actualEndTime || !task.executor) {
|
||||
console.warn('任务缺少必要的工时信息:', task);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算工时
|
||||
const start = new Date(task.actualStartTime);
|
||||
const end = new Date(task.actualEndTime);
|
||||
|
||||
if (end <= start) {
|
||||
console.warn('任务时间无效:', task);
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
// 创建工时记录
|
||||
const workHour: WorkHourRecord = {
|
||||
id: `hour-auto-${task.id}-${Date.now()}`,
|
||||
staffId: task.executorId || 'unknown',
|
||||
staffName: task.executor,
|
||||
taskId: task.id,
|
||||
taskName: task.taskName,
|
||||
date: task.actualStartTime.split('T')[0],
|
||||
startTime: start.toTimeString().slice(0, 5),
|
||||
endTime: end.toTimeString().slice(0, 5),
|
||||
totalHours: parseFloat(totalHours.toFixed(2)),
|
||||
workType: task.operationType || '其他',
|
||||
fieldName: task.fieldName || '',
|
||||
area: task.area || 0,
|
||||
notes: `从任务"${task.taskName}"自动生成`,
|
||||
status: '已审核', // 从任务自动生成的工时默认已审核
|
||||
recordType: '自动记录',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return workHour;
|
||||
} catch (error) {
|
||||
console.error('创建工时记录失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存工时记录到 localStorage
|
||||
*/
|
||||
export function saveWorkHour(workHour: WorkHourRecord): boolean {
|
||||
try {
|
||||
const data = localStorage.getItem('smart_agriculture_work_hours');
|
||||
let hours: WorkHourRecord[] = data ? JSON.parse(data) : [];
|
||||
|
||||
// 检查是否已存在相同任务的工时记录
|
||||
if (workHour.taskId) {
|
||||
const exists = hours.some(h => h.taskId === workHour.taskId);
|
||||
if (exists) {
|
||||
console.log('该任务的工时记录已存在,跳过创建');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新记录
|
||||
hours.unshift(workHour);
|
||||
localStorage.setItem('smart_agriculture_work_hours', JSON.stringify(hours));
|
||||
|
||||
console.log('工时记录已保存:', workHour);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('保存工时记录失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量从任务列表创建工时记录
|
||||
*/
|
||||
export function batchCreateWorkHoursFromTasks(tasks: any[]): number {
|
||||
let count = 0;
|
||||
|
||||
tasks.forEach(task => {
|
||||
// 只处理已完成的任务
|
||||
if (task.status !== '已完成') return;
|
||||
|
||||
const workHour = createWorkHourFromTask(task);
|
||||
if (workHour && saveWorkHour(workHour)) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定员工的工时统计
|
||||
*/
|
||||
export function getStaffWorkHourStats(staffId: string, startDate?: string, endDate?: string): {
|
||||
totalHours: number;
|
||||
approvedHours: number;
|
||||
pendingHours: number;
|
||||
recordCount: number;
|
||||
} {
|
||||
try {
|
||||
const data = localStorage.getItem('smart_agriculture_work_hours');
|
||||
if (!data) {
|
||||
return { totalHours: 0, approvedHours: 0, pendingHours: 0, recordCount: 0 };
|
||||
}
|
||||
|
||||
let hours: WorkHourRecord[] = JSON.parse(data);
|
||||
|
||||
// 筛选指定员工的记录
|
||||
hours = hours.filter(h => h.staffId === staffId);
|
||||
|
||||
// 日期范围筛选
|
||||
if (startDate) {
|
||||
hours = hours.filter(h => h.date >= startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
hours = hours.filter(h => h.date <= endDate);
|
||||
}
|
||||
|
||||
const totalHours = hours.reduce((sum, h) => sum + h.totalHours, 0);
|
||||
const approvedHours = hours
|
||||
.filter(h => h.status === '已审核')
|
||||
.reduce((sum, h) => sum + h.totalHours, 0);
|
||||
const pendingHours = hours
|
||||
.filter(h => h.status === '待审核')
|
||||
.reduce((sum, h) => sum + h.totalHours, 0);
|
||||
|
||||
return {
|
||||
totalHours: parseFloat(totalHours.toFixed(2)),
|
||||
approvedHours: parseFloat(approvedHours.toFixed(2)),
|
||||
pendingHours: parseFloat(pendingHours.toFixed(2)),
|
||||
recordCount: hours.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取工时统计失败:', error);
|
||||
return { totalHours: 0, approvedHours: 0, pendingHours: 0, recordCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出工时记录(用于薪酬核算)
|
||||
*/
|
||||
export function exportWorkHoursForPayroll(startDate: string, endDate: string): any[] {
|
||||
try {
|
||||
const data = localStorage.getItem('smart_agriculture_work_hours');
|
||||
if (!data) return [];
|
||||
|
||||
let hours: WorkHourRecord[] = JSON.parse(data);
|
||||
|
||||
// 筛选日期范围和已审核的记录
|
||||
hours = hours.filter(h =>
|
||||
h.date >= startDate &&
|
||||
h.date <= endDate &&
|
||||
h.status === '已审核'
|
||||
);
|
||||
|
||||
// 按员工汇总
|
||||
const payrollData = new Map<string, any>();
|
||||
|
||||
hours.forEach(h => {
|
||||
if (!payrollData.has(h.staffId)) {
|
||||
payrollData.set(h.staffId, {
|
||||
staffId: h.staffId,
|
||||
staffName: h.staffName,
|
||||
totalHours: 0,
|
||||
recordCount: 0,
|
||||
workTypes: new Map<string, number>(),
|
||||
});
|
||||
}
|
||||
|
||||
const staff = payrollData.get(h.staffId);
|
||||
staff.totalHours += h.totalHours;
|
||||
staff.recordCount++;
|
||||
|
||||
const currentHours = staff.workTypes.get(h.workType) || 0;
|
||||
staff.workTypes.set(h.workType, currentHours + h.totalHours);
|
||||
});
|
||||
|
||||
// 转换为数组格式
|
||||
return Array.from(payrollData.values()).map(staff => ({
|
||||
...staff,
|
||||
totalHours: parseFloat(staff.totalHours.toFixed(2)),
|
||||
workTypes: Object.fromEntries(staff.workTypes),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('导出工时数据失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user