生产管理系统前端 - 地块对比分析,地块适宜性评价开发

This commit is contained in:
2025-10-30 15:03:05 +08:00
parent 2aa93f941e
commit 77bf48f88a
51 changed files with 11252 additions and 96 deletions

View File

@@ -0,0 +1,78 @@
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, X } from 'lucide-react';
import { toast } from 'sonner';
import { FieldData, useChartAnalysis } from './chartAnalysisReducer';
interface FieldSelectorProps {
fields: FieldData[];
}
export function FieldSelector({ fields }: FieldSelectorProps) {
const { state, addField, removeField } = useChartAnalysis();
const availableFields = fields.filter(f => !state.selectedFields.includes(f.id));
const comparisonFields = fields.filter(f => state.selectedFields.includes(f.id));
const handleAddField = (fieldId: string) => {
const success = addField(fieldId);
if (!success) {
toast.error('最多只能同时对比4个地块');
}
};
const handleRemoveField = (fieldId: string) => {
const success = removeField(fieldId);
if (!success) {
toast.error('至少需要选择2个地块进行对比');
}
};
return (
<Card className="p-4 bg-card">
<div className="flex items-start gap-4">
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-2 block"> (2-4)</label>
<div className="flex flex-wrap gap-2">
{comparisonFields.map(field => (
<Badge
key={field.id}
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-3 py-1.5 flex items-center gap-2 font-light"
>
{field.name}
<button
onClick={() => handleRemoveField(field.id)}
className="hover:bg-green-200 dark:hover:bg-green-800 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
{availableFields.length > 0 && (
<Select onValueChange={handleAddField}>
<SelectTrigger className="w-40">
<div className="flex items-center gap-2">
<Plus className="w-4 h-4" />
<span></span>
</div>
</SelectTrigger>
<SelectContent>
{availableFields.map(field => (
<SelectItem key={field.id} value={field.id}>
{field.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Map as MapIcon, MapPin } from 'lucide-react';
import { FieldData, useChartAnalysis } from './chartAnalysisReducer';
export function MapComparison() {
const { state } = useChartAnalysis();
const comparisonFields = state.fields.filter(f => state.selectedFields.includes(f.id));
const getGradeColor = (grade: string) => {
switch (grade) {
case '高度适宜': return 'bg-green-500 text-white';
case '一般适宜': return 'bg-yellow-500 text-white';
case '不适宜': return 'bg-red-500 text-white';
default: return 'bg-gray-500 text-white';
}
};
// 如果没有选择地块,显示空状态
if (comparisonFields.length === 0) {
return (
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<MapIcon className="w-5 h-5 text-blue-600" />
</h3>
<div className="h-64 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<MapIcon className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p></p>
<p className="text-sm mt-2"></p>
</div>
</div>
</Card>
);
}
return (
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<MapIcon className="w-5 h-5 text-blue-600" />
</h3>
<div className="grid grid-cols-2 gap-4">
{comparisonFields.map((field, index) => (
<div key={field.id} className="space-y-2">
<h4 className="text-sm font-medium">{field.name}</h4>
<div className="h-48 bg-gradient-to-br from-green-100 to-green-200 dark:from-green-900 dark:to-green-800 rounded-lg flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="grid grid-cols-8 grid-rows-8 h-full w-full">
{Array.from({ length: 64 }).map((_, i) => (
<div
key={i}
className="border border-green-300 dark:border-green-600"
style={{
backgroundColor: `rgba(34, 197, 94, ${0.1 + (field.suitabilityScore / 100) * 0.6})`,
}}
/>
))}
</div>
</div>
<div className="relative text-center">
<MapPin className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-2" />
<p className="text-sm font-medium">{field.location}</p>
<Badge className={`mt-2 ${getGradeColor(field.suitabilityGrade)}`}>
{field.suitabilityGrade}
</Badge>
</div>
</div>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { Card } from '@/components/ui/card';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts';
import { Scale } from 'lucide-react';
import { FieldData, useChartAnalysis } from './chartAnalysisReducer';
export function NutrientComparison() {
const { state } = useChartAnalysis();
const comparisonFields = state.fields.filter(f => state.selectedFields.includes(f.id));
// 养分对比数据
const nutrientData = comparisonFields.map(field => ({
name: field.name,
全氮: field.nitrogen,
全磷: field.phosphorus,
全钾: field.potassium,
}));
// 如果没有选择地块,显示空状态
if (comparisonFields.length === 0) {
return (
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<Scale className="w-5 h-5 text-orange-600" />
(g/kg)
</h3>
<div className="h-80 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Scale className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm"></p>
</div>
</div>
</Card>
);
}
return (
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<Scale className="w-5 h-5 text-orange-600" />
(g/kg)
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={nutrientData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="全氮" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="全磷" stroke="#3b82f6" strokeWidth={2} />
<Line type="monotone" dataKey="全钾" stroke="#f59e0b" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
</Card>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { Card } from '@/components/ui/card';
import {
RadarChart,
Radar as RechartsRadar,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
Legend,
} from 'recharts';
import { Radar } from 'lucide-react';
import { FieldData, useChartAnalysis } from './chartAnalysisReducer';
export function ChartRadarAnalysis() {
const { state } = useChartAnalysis();
const comparisonFields = state.fields.filter(f => state.selectedFields.includes(f.id));
// 雷达图数据
const radarData = [
{
indicator: 'pH值',
...comparisonFields.reduce((acc, field) => ({
...acc,
[field.name]: (field.ph / 10) * 100,
}), {}),
},
{
indicator: '有机质',
...comparisonFields.reduce((acc, field) => ({
...acc,
[field.name]: (field.organicMatter / 40) * 100,
}), {}),
},
{
indicator: '全氮',
...comparisonFields.reduce((acc, field) => ({
...acc,
[field.name]: (field.nitrogen / 2.5) * 100,
}), {}),
},
{
indicator: '全磷',
...comparisonFields.reduce((acc, field) => ({
...acc,
[field.name]: (field.phosphorus / 2.0) * 100,
}), {}),
},
{
indicator: '全钾',
...comparisonFields.reduce((acc, field) => ({
...acc,
[field.name]: (field.potassium / 25) * 100,
}), {}),
},
{
indicator: '土层厚度',
...comparisonFields.reduce((acc, field) => ({
...acc,
[field.name]: (field.soilDepth / 100) * 100,
}), {}),
},
];
const colors = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
// 如果没有选择地块,显示空状态
if (comparisonFields.length === 0) {
return (
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<Radar className="w-5 h-5 text-blue-600" />
</h3>
<p className="text-sm text-muted-foreground mb-4">
</p>
<div className="h-96 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Radar className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p></p>
<p className="text-sm mt-2"></p>
</div>
</div>
</Card>
);
}
return (
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<Radar className="w-5 h-5 text-blue-600" />
</h3>
<p className="text-sm text-muted-foreground mb-4">
</p>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="indicator" />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
{comparisonFields.map((field, index) => (
<RechartsRadar
key={field.id}
name={field.name}
dataKey={field.name}
stroke={colors[index]}
fill={colors[index]}
fillOpacity={0.3}
/>
))}
<Legend />
</RadarChart>
</ResponsiveContainer>
</div>
</Card>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { Card } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts';
import { BarChart3, TrendingUp } from 'lucide-react';
import { FieldData, useChartAnalysis } from './chartAnalysisReducer';
export function YieldComparison() {
const { state } = useChartAnalysis();
const comparisonFields = state.fields.filter(f => state.selectedFields.includes(f.id));
// 产量对比数据
const yieldData = comparisonFields.map(field => ({
name: field.name,
产量: field.yield,
有机质: field.organicMatter,
}));
// 如果没有选择地块,显示空状态
if (comparisonFields.length === 0) {
return (
<div className="grid grid-cols-2 gap-4">
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-green-600" />
(kg/)
</h3>
<div className="h-72 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<BarChart3 className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm"></p>
</div>
</div>
</Card>
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
(g/kg)
</h3>
<div className="h-72 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<TrendingUp className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm"></p>
</div>
</div>
</Card>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-4">
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-green-600" />
(kg/)
</h3>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={yieldData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="产量" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
<Card className="p-6 bg-card">
<h3 className="mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
(g/kg)
</h3>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={yieldData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="有机质" fill="#8b5cf6" />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useReducer, useCallback } from 'react';
export interface FieldData {
id: string;
name: string;
area: number;
location: string;
soilType: string;
ph: number;
organicMatter: number;
nitrogen: number;
phosphorus: number;
potassium: number;
soilDepth: number;
slope: number;
currentCrop: string;
yield: number;
suitabilityScore: number;
suitabilityGrade: '高度适宜' | '一般适宜' | '不适宜';
irrigation: string;
drainage: string;
}
export interface ChartAnalysisState {
selectedFields: string[];
fields: FieldData[];
}
export type ChartAnalysisAction =
| { type: 'SET_FIELDS'; payload: FieldData[] }
| { type: 'ADD_FIELD'; payload: string }
| { type: 'REMOVE_FIELD'; payload: string }
| { type: 'SET_SELECTED_FIELDS'; payload: string[] };
const initialState: ChartAnalysisState = {
selectedFields: ['field-1', 'field-2'],
fields: [],
};
export function chartAnalysisReducer(state: ChartAnalysisState, action: ChartAnalysisAction): ChartAnalysisState {
switch (action.type) {
case 'SET_FIELDS':
return {
...state,
fields: action.payload,
};
case 'ADD_FIELD':
if (state.selectedFields.length >= 4) {
return state;
}
return {
...state,
selectedFields: [...state.selectedFields, action.payload],
};
case 'REMOVE_FIELD':
if (state.selectedFields.length <= 2) {
return state;
}
return {
...state,
selectedFields: state.selectedFields.filter(id => id !== action.payload),
};
case 'SET_SELECTED_FIELDS':
return {
...state,
selectedFields: action.payload,
};
default:
return state;
}
}
export function useChartAnalysis() {
const [state, dispatch] = useReducer(chartAnalysisReducer, initialState);
const addField = useCallback((fieldId: string) => {
if (state.selectedFields.length >= 4) {
return false;
}
dispatch({ type: 'ADD_FIELD', payload: fieldId });
return true;
}, [state.selectedFields.length]);
const removeField = useCallback((fieldId: string) => {
if (state.selectedFields.length <= 2) {
return false;
}
dispatch({ type: 'REMOVE_FIELD', payload: fieldId });
return true;
}, [state.selectedFields.length]);
const setSelectedFields = useCallback((fieldIds: string[]) => {
dispatch({ type: 'SET_SELECTED_FIELDS', payload: fieldIds });
}, []);
const setFields = useCallback((fields: FieldData[]) => {
dispatch({ type: 'SET_FIELDS', payload: fields });
}, []);
return {
state,
dispatch,
addField,
removeField,
setSelectedFields,
setFields,
};
}

View File

@@ -1,18 +1,158 @@
'use client';
import { useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { toast } from 'sonner';
import { FieldSelector } from './components/FieldSelector';
import { ChartRadarAnalysis } from './components/RadarChart';
import { YieldComparison } from './components/YieldComparison';
import { NutrientComparison } from './components/NutrientComparison';
import { MapComparison } from './components/MapComparison';
import { useChartAnalysis, FieldData } from './components/chartAnalysisReducer';
// 模拟地块数据
const mockFieldsData: FieldData[] = [
{
id: 'field-1',
name: '东区1号地',
area: 50.5,
location: '东经120.15°, 北纬30.25°',
soilType: '壤土',
ph: 6.5,
organicMatter: 32,
nitrogen: 1.8,
phosphorus: 1.2,
potassium: 18,
soilDepth: 85,
slope: 3,
currentCrop: '水稻',
yield: 750,
suitabilityScore: 87,
suitabilityGrade: '高度适宜',
irrigation: '喷灌',
drainage: '良好',
},
{
id: 'field-2',
name: '西区2号地',
area: 45.2,
location: '东经120.12°, 北纬30.28°',
soilType: '粘土',
ph: 7.8,
organicMatter: 22,
nitrogen: 1.3,
phosphorus: 0.9,
potassium: 14,
soilDepth: 55,
slope: 5,
currentCrop: '玉米',
yield: 650,
suitabilityScore: 72,
suitabilityGrade: '一般适宜',
irrigation: '滴灌',
drainage: '中等',
},
{
id: 'field-3',
name: '南区3号地',
area: 38.8,
location: '东经120.18°, 北纬30.22°',
soilType: '砂土',
ph: 8.5,
organicMatter: 15,
nitrogen: 0.8,
phosphorus: 0.6,
potassium: 10,
soilDepth: 42,
slope: 8,
currentCrop: '小麦',
yield: 480,
suitabilityScore: 58,
suitabilityGrade: '不适宜',
irrigation: '漫灌',
drainage: '较差',
},
{
id: 'field-4',
name: '北区4号地',
area: 55.0,
location: '东经120.20°, 北纬30.30°',
soilType: '壤土',
ph: 6.8,
organicMatter: 28,
nitrogen: 1.6,
phosphorus: 1.0,
potassium: 16,
soilDepth: 75,
slope: 2,
currentCrop: '大豆',
yield: 380,
suitabilityScore: 82,
suitabilityGrade: '高度适宜',
irrigation: '喷灌',
drainage: '良好',
},
];
export default function ChartPage() {
const { setFields, state } = useChartAnalysis();
// 初始化数据
useEffect(() => {
// 直接使用模拟数据,确保数据可用
setFields(mockFieldsData);
localStorage.setItem('chart-analysis-fields', JSON.stringify(mockFieldsData));
}, [setFields]);
// 如果数据还没有加载显示loading状态
if (state.fields.length === 0) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
</div>
<Card className="p-6 bg-card">
<div className="text-center py-8">
<p>...</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/comparison/chart
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
</Card>
</div>
{/* 地块选择器 */}
<FieldSelector fields={state.fields} />
{/* 图表分析区域 */}
<div className="space-y-4">
{/* 雷达图 */}
<ChartRadarAnalysis />
{/* 产量与有机质对比 */}
<YieldComparison />
{/* 养分对比 */}
<NutrientComparison />
{/* 地图对比 */}
<MapComparison />
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { MapPin } from 'lucide-react';
import { FieldData } from './multiDimensionReducer';
interface BasicPropertiesProps {
comparisonFields: FieldData[];
}
export function BasicProperties({ comparisonFields }: BasicPropertiesProps) {
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<MapPin className="w-5 h-5 text-blue-600" />
<h3></h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-xs"></th>
{comparisonFields.map(field => (
<th key={field.id} className="px-4 py-3 text-center text-xs">
{field.name}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"> ()</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
{field.area}
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
{field.location}
</td>
))}
</tr>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<Badge variant="outline">{field.soilType}</Badge>
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"> (°)</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<span className={field.slope <= 3 ? 'text-green-600' : field.slope <= 5 ? 'text-yellow-600' : 'text-red-600'}>
{field.slope}
</span>
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, X } from 'lucide-react';
import { FieldData } from './multiDimensionReducer';
interface FieldSelectorProps {
selectedFields: string[];
fieldsData: FieldData[];
onAddField: (fieldId: string) => void;
onRemoveField: (fieldId: string) => void;
}
export function FieldSelector({
selectedFields,
fieldsData,
onAddField,
onRemoveField
}: FieldSelectorProps) {
const comparisonFields = fieldsData.filter(f => selectedFields.includes(f.id));
const availableFields = fieldsData.filter(f => !selectedFields.includes(f.id));
const getGradeColor = (grade: string) => {
switch (grade) {
case '高度适宜': return 'bg-green-500 text-white';
case '一般适宜': return 'bg-yellow-500 text-white';
case '不适宜': return 'bg-red-500 text-white';
default: return 'bg-gray-500 text-white';
}
};
return (
<Card className="p-4">
<div className="flex items-start gap-4">
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-2 block"> (2-4)</label>
<div className="flex flex-wrap gap-2 mb-3">
{comparisonFields.map(field => (
<Badge
key={field.id}
className="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-3 py-1.5 flex items-center gap-2 border border-green-200 dark:border-green-800"
>
{field.name}
<button
onClick={() => onRemoveField(field.id)}
className="hover:bg-green-200 dark:hover:bg-green-800 rounded-full p-0.5 transition-colors"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
{availableFields.length > 0 && (
<Select onValueChange={onAddField}>
<SelectTrigger className="w-40">
<div className="flex items-center gap-2">
<Plus className="w-4 h-4" />
<span></span>
</div>
</SelectTrigger>
<SelectContent>
{availableFields.map(field => (
<SelectItem key={field.id} value={field.id}>
{field.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{selectedFields.length < 2 && (
<p className="text-xs text-muted-foreground">
2
</p>
)}
{selectedFields.length >= 4 && (
<p className="text-xs text-muted-foreground">
4
</p>
)}
{/* 选中地块的简要信息 */}
{comparisonFields.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-xs text-muted-foreground mb-2"></p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{comparisonFields.map(field => (
<div key={field.id} className="p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium truncate">{field.name}</span>
<Badge className={`${getGradeColor(field.suitabilityGrade)} text-xs`}>
{field.suitabilityGrade}
</Badge>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex justify-between">
<span>:</span>
<span>{field.area}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{field.currentCrop}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{field.yield}kg/</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{field.suitabilityScore}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { BarChart3 } from 'lucide-react';
import { FieldData } from './multiDimensionReducer';
interface ManagementStatusProps {
comparisonFields: FieldData[];
}
export function ManagementStatus({ comparisonFields }: ManagementStatusProps) {
const getGradeColor = (grade: string) => {
switch (grade) {
case '高度适宜': return 'bg-green-500 text-white';
case '一般适宜': return 'bg-yellow-500 text-white';
case '不适宜': return 'bg-red-500 text-white';
default: return 'bg-gray-500 text-white';
}
};
const getYieldColor = (yieldValue: number) => {
if (yieldValue >= 700) return 'text-green-600';
if (yieldValue >= 500) return 'text-yellow-600';
return 'text-red-600';
};
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<BarChart3 className="w-5 h-5 text-purple-600" />
<h3></h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-xs"></th>
{comparisonFields.map(field => (
<th key={field.id} className="px-4 py-3 text-center text-xs">
{field.name}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<Badge variant="outline">{field.currentCrop}</Badge>
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"> (kg/)</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center">
<span className={`text-sm font-medium ${getYieldColor(field.yield)}`}>
{field.yield}
</span>
<div className="text-xs text-muted-foreground mt-1">
{field.yield >= 700 ? '高产' : field.yield >= 500 ? '中产' : '低产'}
</div>
</td>
))}
</tr>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<Badge
variant={field.irrigation === '喷灌' ? 'default' :
field.irrigation === '滴灌' ? 'secondary' : 'outline'}
>
{field.irrigation}
</Badge>
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<Badge
variant={field.drainage === '良好' ? 'default' :
field.drainage === '中等' ? 'secondary' : 'destructive'}
>
{field.drainage}
</Badge>
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Sprout } from 'lucide-react';
import { CheckCircle2, AlertCircle } from 'lucide-react';
import { FieldData } from './multiDimensionReducer';
interface NaturalConditionsProps {
comparisonFields: FieldData[];
}
export function NaturalConditions({ comparisonFields }: NaturalConditionsProps) {
const getPhStatus = (ph: number) => {
if (ph >= 6.0 && ph <= 7.5) return 'optimal';
return 'deviation';
};
const getOrganicStatus = (organic: number) => {
if (organic >= 25) return 'optimal';
return 'deviation';
};
const getDepthStatus = (depth: number) => {
if (depth >= 60) return 'optimal';
return 'deviation';
};
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Sprout className="w-5 h-5 text-green-600" />
<h3></h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-xs"></th>
{comparisonFields.map(field => (
<th key={field.id} className="px-4 py-3 text-center text-xs">
{field.name}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium">pH值</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
<span className="text-sm">{field.ph}</span>
{getPhStatus(field.ph) === 'optimal' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<AlertCircle className="w-4 h-4 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
最佳: 6.0-7.5
</div>
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"> (g/kg)</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
<span className="text-sm">{field.organicMatter}</span>
{getOrganicStatus(field.organicMatter) === 'optimal' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<AlertCircle className="w-4 h-4 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
: 25
</div>
</td>
))}
</tr>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"> (g/kg)</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<span className={field.nitrogen >= 1.5 ? 'text-green-600' : field.nitrogen >= 1.0 ? 'text-yellow-600' : 'text-red-600'}>
{field.nitrogen}
</span>
<div className="text-xs text-muted-foreground mt-1">
: 1.5
</div>
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"> (g/kg)</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<span className={field.phosphorus >= 1.0 ? 'text-green-600' : field.phosphorus >= 0.6 ? 'text-yellow-600' : 'text-red-600'}>
{field.phosphorus}
</span>
<div className="text-xs text-muted-foreground mt-1">
: 1.0
</div>
</td>
))}
</tr>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"> (g/kg)</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<span className={field.potassium >= 15 ? 'text-green-600' : field.potassium >= 10 ? 'text-yellow-600' : 'text-red-600'}>
{field.potassium}
</span>
<div className="text-xs text-muted-foreground mt-1">
: 15
</div>
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"> (cm)</td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
<span className="text-sm">{field.soilDepth}</span>
{getDepthStatus(field.soilDepth) === 'optimal' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<AlertCircle className="w-4 h-4 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
: 60
</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Target } from 'lucide-react';
import { FieldData } from './multiDimensionReducer';
interface SuitabilityEvaluationProps {
comparisonFields: FieldData[];
}
export function SuitabilityEvaluation({ comparisonFields }: SuitabilityEvaluationProps) {
const getGradeColor = (grade: string) => {
switch (grade) {
case '高度适宜': return 'bg-green-500 text-white';
case '一般适宜': return 'bg-yellow-500 text-white';
case '不适宜': return 'bg-red-500 text-white';
default: return 'bg-gray-500 text-white';
}
};
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-green-600 dark:text-green-400';
if (score >= 60) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
};
const getScoreLevel = (score: number) => {
if (score >= 80) return '优秀';
if (score >= 60) return '良好';
return '需改善';
};
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Target className="w-5 h-5 text-orange-600" />
<h3></h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-xs"></th>
{comparisonFields.map(field => (
<th key={field.id} className="px-4 py-3 text-center text-xs">
{field.name}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center">
<div className="space-y-1">
<span className={`text-2xl font-bold ${getScoreColor(field.suitabilityScore)}`}>
{field.suitabilityScore}
</span>
<div className="text-xs text-muted-foreground">
{getScoreLevel(field.suitabilityScore)}
</div>
</div>
</td>
))}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center">
<div className="space-y-2">
<Badge className={`${getGradeColor(field.suitabilityGrade)} text-sm px-3 py-1`}>
{field.suitabilityGrade}
</Badge>
<div className="text-xs text-muted-foreground">
{field.suitabilityGrade === '高度适宜' ? '适合多种作物' :
field.suitabilityGrade === '一般适宜' ? '适合耐性作物' : '需系统改良'}
</div>
</div>
</td>
))}
</tr>
<tr className="border-t border-border">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => {
const limitations = [];
if (field.ph < 6.0 || field.ph > 7.5) limitations.push('pH值');
if (field.organicMatter < 25) limitations.push('有机质');
if (field.soilDepth < 60) limitations.push('土层厚度');
if (field.nitrogen < 1.5) limitations.push('全氮');
if (field.phosphorus < 1.0) limitations.push('全磷');
if (field.potassium < 15) limitations.push('全钾');
return (
<td key={field.id} className="px-4 py-3 text-center text-sm">
{limitations.length > 0 ? (
<div className="space-y-1">
{limitations.slice(0, 2).map((limit, index) => (
<Badge key={index} variant="outline" className="text-xs">
{limit}
</Badge>
))}
{limitations.length > 2 && (
<span className="text-xs text-muted-foreground">
+{limitations.length - 2}
</span>
)}
</div>
) : (
<span className="text-xs text-green-600"></span>
)}
</td>
);
})}
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 text-sm font-medium"></td>
{comparisonFields.map(field => (
<td key={field.id} className="px-4 py-3 text-center text-sm">
<div className="space-y-1">
{field.suitabilityGrade === '高度适宜' ? (
<>
<Badge className="bg-green-100 text-green-800 border-green-200" variant="outline">
</Badge>
<Badge className="bg-blue-100 text-blue-800 border-blue-200" variant="outline">
</Badge>
</>
) : field.suitabilityGrade === '一般适宜' ? (
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-200" variant="outline">
</Badge>
) : (
<Badge className="bg-red-100 text-red-800 border-red-200" variant="outline">
</Badge>
)}
</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
export interface FieldData {
id: string;
name: string;
area: number;
location: string;
soilType: string;
ph: number;
organicMatter: number;
nitrogen: number;
phosphorus: number;
potassium: number;
soilDepth: number;
slope: number;
currentCrop: string;
yield: number;
suitabilityScore: number;
suitabilityGrade: '高度适宜' | '一般适宜' | '不适宜';
irrigation: string;
drainage: string;
}
export interface MultiDimensionState {
selectedFields: string[];
fieldsData: FieldData[];
}
export type MultiDimensionAction =
| { type: 'SET_SELECTED_FIELDS'; payload: string[] }
| { type: 'ADD_FIELD'; payload: string }
| { type: 'REMOVE_FIELD'; payload: string }
| { type: 'RESET_FIELDS' };
// 模拟地块数据
const initialFieldsData: FieldData[] = [
{
id: 'field-1',
name: '东区1号地',
area: 50.5,
location: '东经120.15°, 北纬30.25°',
soilType: '壤土',
ph: 6.5,
organicMatter: 32,
nitrogen: 1.8,
phosphorus: 1.2,
potassium: 18,
soilDepth: 85,
slope: 3,
currentCrop: '水稻',
yield: 750,
suitabilityScore: 87,
suitabilityGrade: '高度适宜',
irrigation: '喷灌',
drainage: '良好',
},
{
id: 'field-2',
name: '西区2号地',
area: 45.2,
location: '东经120.12°, 北纬30.28°',
soilType: '粘土',
ph: 7.8,
organicMatter: 22,
nitrogen: 1.3,
phosphorus: 0.9,
potassium: 14,
soilDepth: 55,
slope: 5,
currentCrop: '玉米',
yield: 650,
suitabilityScore: 72,
suitabilityGrade: '一般适宜',
irrigation: '滴灌',
drainage: '中等',
},
{
id: 'field-3',
name: '南区3号地',
area: 38.8,
location: '东经120.18°, 北纬30.22°',
soilType: '砂土',
ph: 8.5,
organicMatter: 15,
nitrogen: 0.8,
phosphorus: 0.6,
potassium: 10,
soilDepth: 42,
slope: 8,
currentCrop: '小麦',
yield: 480,
suitabilityScore: 58,
suitabilityGrade: '不适宜',
irrigation: '漫灌',
drainage: '较差',
},
{
id: 'field-4',
name: '北区4号地',
area: 55.0,
location: '东经120.20°, 北纬30.30°',
soilType: '壤土',
ph: 6.8,
organicMatter: 28,
nitrogen: 1.6,
phosphorus: 1.0,
potassium: 16,
soilDepth: 75,
slope: 2,
currentCrop: '大豆',
yield: 380,
suitabilityScore: 82,
suitabilityGrade: '高度适宜',
irrigation: '喷灌',
drainage: '良好',
},
];
export const initialState: MultiDimensionState = {
selectedFields: ['field-1', 'field-2'],
fieldsData: initialFieldsData,
};
export function multiDimensionReducer(
state: MultiDimensionState,
action: MultiDimensionAction
): MultiDimensionState {
switch (action.type) {
case 'SET_SELECTED_FIELDS':
return { ...state, selectedFields: action.payload };
case 'ADD_FIELD':
if (state.selectedFields.length >= 4) {
return state; // 最多只能同时对比4个地块
}
return { ...state, selectedFields: [...state.selectedFields, action.payload] };
case 'REMOVE_FIELD':
if (state.selectedFields.length <= 2) {
return state; // 至少需要选择2个地块进行对比
}
return {
...state,
selectedFields: state.selectedFields.filter(id => id !== action.payload)
};
case 'RESET_FIELDS':
return { ...state, selectedFields: ['field-1', 'field-2'] };
default:
return state;
}
}

View File

@@ -1,18 +1,235 @@
'use client';
import { Card } from '@/components/ui/card';
import { useReducer } from 'react';
import { toast } from 'sonner';
import {
multiDimensionReducer,
initialState,
MultiDimensionAction,
MultiDimensionState,
FieldData
} from './components/multiDimensionReducer';
import { FieldSelector } from './components/FieldSelector';
import { BasicProperties } from './components/BasicProperties';
import { NaturalConditions } from './components/NaturalConditions';
import { ManagementStatus } from './components/ManagementStatus';
import { SuitabilityEvaluation } from './components/SuitabilityEvaluation';
export default function MultiDimensionPage() {
const [state, dispatch] = useReducer(multiDimensionReducer, initialState);
// 获取当前选中的对比地块
const comparisonFields = state.fieldsData.filter(f => state.selectedFields.includes(f.id));
const handleAddField = (fieldId: string) => {
if (state.selectedFields.length >= 4) {
toast.error('最多只能同时对比4个地块');
return;
}
dispatch({ type: 'ADD_FIELD', payload: fieldId });
};
const handleRemoveField = (fieldId: string) => {
if (state.selectedFields.length <= 2) {
toast.error('至少需要选择2个地块进行对比');
return;
}
dispatch({ type: 'REMOVE_FIELD', payload: fieldId });
};
// 生成统计分析
const generateStatistics = () => {
if (comparisonFields.length === 0) return null;
const totalArea = comparisonFields.reduce((sum, f) => sum + f.area, 0);
const avgYield = Math.round(comparisonFields.reduce((sum, f) => sum + f.yield, 0) / comparisonFields.length);
const avgScore = Math.round(comparisonFields.reduce((sum, f) => sum + f.suitabilityScore, 0) / comparisonFields.length);
const avgOrganic = Math.round(comparisonFields.reduce((sum, f) => sum + f.organicMatter, 0) / comparisonFields.length);
const highSuitabilityCount = comparisonFields.filter(f => f.suitabilityGrade === '高度适宜').length;
const mediumSuitabilityCount = comparisonFields.filter(f => f.suitabilityGrade === '一般适宜').length;
const lowSuitabilityCount = comparisonFields.filter(f => f.suitabilityGrade === '不适宜').length;
return {
totalArea,
avgYield,
avgScore,
avgOrganic,
highSuitabilityCount,
mediumSuitabilityCount,
lowSuitabilityCount,
bestField: comparisonFields.sort((a, b) => b.suitabilityScore - a.suitabilityScore)[0],
highestYieldField: comparisonFields.sort((a, b) => b.yield - a.yield)[0],
bestOrganicField: comparisonFields.sort((a, b) => b.organicMatter - a.organicMatter)[0],
};
};
const statistics = generateStatistics();
export default function IndicatorPage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/comparison/indicator
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
</Card>
</div>
{/* 地块选择器 */}
<FieldSelector
selectedFields={state.selectedFields}
fieldsData={state.fieldsData}
onAddField={handleAddField}
onRemoveField={handleRemoveField}
/>
{/* 统计概览 */}
{statistics && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-center">
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400 mb-1">
{statistics.totalArea}
</p>
<p className="text-xs text-green-700 dark:text-green-300"></p>
</div>
</div>
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="text-center">
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mb-1">
{statistics.avgYield}
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">kg/</p>
</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="text-center">
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400 mb-1">
{statistics.avgScore}
</p>
<p className="text-xs text-purple-700 dark:text-purple-300"></p>
</div>
</div>
<div className="p-4 bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-950 dark:to-amber-900 rounded-lg border border-amber-200 dark:border-amber-800">
<div className="text-center">
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-1">
{statistics.avgOrganic}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">g/kg</p>
</div>
</div>
</div>
)}
{/* 适宜性分布统计 */}
{statistics && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-xl font-bold text-green-600 dark:text-green-400">
{statistics.highSuitabilityCount}
</p>
</div>
<div className="text-3xl text-green-400 opacity-20">📊</div>
</div>
</div>
<div className="p-4 bg-yellow-50 dark:bg-yellow-950 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-xl font-bold text-yellow-600 dark:text-yellow-400">
{statistics.mediumSuitabilityCount}
</p>
</div>
<div className="text-3xl text-yellow-400 opacity-20">📈</div>
</div>
</div>
<div className="p-4 bg-red-50 dark:bg-red-950 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-xl font-bold text-red-600 dark:text-red-400">
{statistics.lowSuitabilityCount}
</p>
</div>
<div className="text-3xl text-red-400 opacity-20"></div>
</div>
</div>
</div>
)}
{/* 详细对比表格 */}
{comparisonFields.length > 0 && (
<div className="space-y-6">
{/* 基础属性对比 */}
<BasicProperties comparisonFields={comparisonFields} />
{/* 自然条件对比 */}
<NaturalConditions comparisonFields={comparisonFields} />
{/* 经营现状对比 */}
<ManagementStatus comparisonFields={comparisonFields} />
{/* 适宜性评价对比 */}
<SuitabilityEvaluation comparisonFields={comparisonFields} />
</div>
)}
{/* 优秀地块展示 */}
{statistics && comparisonFields.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="p-4 bg-gradient-to-br from-green-50 to-white dark:from-green-950 dark:to-card rounded-lg border border-green-200 dark:border-green-800">
<h4 className="text-sm font-medium text-green-800 dark:text-green-200 mb-2">🏆 </h4>
<div className="space-y-1">
<p className="font-medium">{statistics.bestField.name}</p>
<p className="text-xs text-muted-foreground">: {statistics.bestField.suitabilityScore}</p>
<p className="text-xs text-muted-foreground">: {statistics.bestField.yield}kg/</p>
</div>
</div>
<div className="p-4 bg-gradient-to-br from-blue-50 to-white dark:from-blue-950 dark:to-card rounded-lg border border-blue-200 dark:border-blue-800">
<h4 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">🌾 </h4>
<div className="space-y-1">
<p className="font-medium">{statistics.highestYieldField.name}</p>
<p className="text-xs text-muted-foreground">: {statistics.highestYieldField.yield}kg/</p>
<p className="text-xs text-muted-foreground">: {statistics.highestYieldField.suitabilityScore}</p>
</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-white dark:from-purple-950 dark:to-card rounded-lg border border-purple-200 dark:border-purple-800">
<h4 className="text-sm font-medium text-purple-800 dark:text-purple-200 mb-2">🌱 </h4>
<div className="space-y-1">
<p className="font-medium">{statistics.bestOrganicField.name}</p>
<p className="text-xs text-muted-foreground">: {statistics.bestOrganicField.organicMatter}g/kg</p>
<p className="text-xs text-muted-foreground">: {statistics.bestOrganicField.suitabilityScore}</p>
</div>
</div>
</div>
)}
{/* 空状态 */}
{comparisonFields.length === 0 && (
<div className="p-12 bg-card rounded-lg border text-center">
<div className="text-muted-foreground">
<p className="text-lg mb-2"></p>
<p className="text-sm">
2-4
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Progress } from '@/components/ui/progress';
import { Plus, X, FileText } from 'lucide-react';
import { ReportComparisonState, FieldData } from './reportComparisonReducer';
interface FieldSelectorProps {
state: ReportComparisonState;
onAddField: (fieldId: string) => void;
onRemoveField: (fieldId: string) => void;
onGenerateReport: () => void;
fieldsData: FieldData[];
}
export function FieldSelector({
state,
onAddField,
onRemoveField,
onGenerateReport,
fieldsData
}: FieldSelectorProps) {
const availableFields = fieldsData.filter(f => !state.selectedFields.includes(f.id));
const comparisonFields = fieldsData.filter(f => state.selectedFields.includes(f.id));
return (
<Card className="p-4">
<div className="flex items-start gap-4">
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-2 block"> (2-4)</label>
<div className="flex flex-wrap gap-2">
{comparisonFields.map(field => (
<Badge
key={field.id}
className="bg-green-100 text-green-800 px-3 py-1.5 flex items-center gap-2 font-light"
>
{field.name}
<button
onClick={() => onRemoveField(field.id)}
className="hover:bg-green-200 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
{availableFields.length > 0 && (
<Select onValueChange={onAddField}>
<SelectTrigger className="w-40">
<div className="flex items-center gap-2">
<Plus className="w-4 h-4" />
<span></span>
</div>
</SelectTrigger>
<SelectContent>
{availableFields.map(field => (
<SelectItem key={field.id} value={field.id}>
{field.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="flex-shrink-0">
<label className="text-xs text-muted-foreground mb-2 block opacity-0"></label>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={onGenerateReport}
disabled={state.reportGenerating}
>
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{state.reportGenerating && (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{state.reportProgress}%</span>
</div>
<Progress value={state.reportProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-center mt-2">
...
</p>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FileText, Clock, MapPin, Download, X } from 'lucide-react';
import { ComparisonReport } from './reportComparisonReducer';
interface ReportListProps {
savedReports: ComparisonReport[];
onDownloadPDF: (report: ComparisonReport) => void;
onDownloadWord: (report: ComparisonReport) => void;
onDeleteReport: (reportId: string) => void;
}
export function ReportList({
savedReports,
onDownloadPDF,
onDownloadWord,
onDeleteReport
}: ReportListProps) {
return (
<Card className="p-6">
<h3 className="mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-green-600" />
</h3>
{savedReports.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p></p>
<p className="text-sm mt-1">"生成报告"</p>
</div>
) : (
<div className="space-y-3">
{savedReports.map((report) => (
<div
key={report.id}
className="p-4 border rounded-lg hover:bg-muted transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-green-600" />
{report.name}
</h4>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
: {report.generatedTime}
</span>
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
: {report.comparedFields.join('、')}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
size="sm"
variant="outline"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => onDownloadPDF(report)}
>
<Download className="w-4 h-4 mr-1" />
PDF
</Button>
<Button
size="sm"
variant="outline"
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
onClick={() => onDownloadWord(report)}
>
<Download className="w-4 h-4 mr-1" />
Word
</Button>
<Button
size="sm"
variant="outline"
className="text-gray-600 hover:text-gray-700 hover:bg-gray-100"
onClick={() => onDeleteReport(report.id)}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Eye, Zap, Lightbulb, Target, ArrowRight, CheckCircle2, Download } from 'lucide-react';
import { FieldData } from './reportComparisonReducer';
interface ReportPreviewProps {
comparisonFields: FieldData[];
onDownloadPDF: () => void;
onDownloadWord: () => void;
}
export function ReportPreview({
comparisonFields,
onDownloadPDF,
onDownloadWord
}: ReportPreviewProps) {
const getGradeColor = (grade: string) => {
switch (grade) {
case '高度适宜': return 'bg-green-500 text-white';
case '一般适宜': return 'bg-yellow-500 text-white';
case '不适宜': return 'bg-red-500 text-white';
default: return 'bg-gray-500 text-white';
}
};
// 生成智能分析结论
const generateAnalysis = () => {
const analyses: string[] = [];
// 对比产量和有机质
const sortedByYield = [...comparisonFields].sort((a, b) => b.yield - a.yield);
const highestYield = sortedByYield[0];
const lowestYield = sortedByYield[sortedByYield.length - 1];
if (highestYield.organicMatter > lowestYield.organicMatter) {
const diff = ((highestYield.organicMatter - lowestYield.organicMatter) / lowestYield.organicMatter * 100).toFixed(1);
analyses.push(
`${highestYield.name}产量高于${lowestYield.name},主要原因是其有机质含量高出${diff}%,建议${lowestYield.name}增施有机肥。`
);
}
// 对比pH值
const acidFields = comparisonFields.filter(f => f.ph < 6.0);
const alkalineFields = comparisonFields.filter(f => f.ph > 7.5);
if (acidFields.length > 0) {
analyses.push(
`${acidFields.map(f => f.name).join('、')}的pH值偏低建议施用石灰或碱性肥料改良。`
);
}
if (alkalineFields.length > 0) {
analyses.push(
`${alkalineFields.map(f => f.name).join('、')}的pH值偏高建议施用硫磺或酸性肥料降低pH值。`
);
}
// 对比土层厚度
const shallowFields = comparisonFields.filter(f => f.soilDepth < 60);
if (shallowFields.length > 0) {
analyses.push(
`${shallowFields.map(f => f.name).join('、')}的土层厚度不足,建议进行深耕深松,提高土壤蓄水保肥能力。`
);
}
// 对比适宜性
const highSuitability = comparisonFields.filter(f => f.suitabilityScore >= 80);
const lowSuitability = comparisonFields.filter(f => f.suitabilityScore < 60);
if (highSuitability.length > 0 && lowSuitability.length > 0) {
analyses.push(
`${highSuitability.map(f => f.name).join('、')}综合适宜性较高,可优先种植高价值经济作物;${lowSuitability.map(f => f.name).join('、')}需要进行系统改良。`
);
}
return analyses;
};
const smartAnalyses = generateAnalysis();
return (
<div className="space-y-4">
{/* 报告预览 */}
<Card className="p-6">
<h3 className="mb-4 flex items-center gap-2">
<Eye className="w-5 h-5 text-blue-600" />
</h3>
{/* 报告标题 */}
<div className="mb-6 p-4 bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950 rounded-lg border-l-4 border-green-600">
<h2 className="text-green-800 dark:text-green-200 mb-2"></h2>
<p className="text-sm text-muted-foreground">
: {new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).replace(/\//g, '-')} | : {comparisonFields.map(f => f.name).join('、')}
</p>
</div>
{/* 执行摘要 */}
<div className="mb-6">
<h4 className="mb-3 flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-600" />
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-green-50 dark:bg-green-950 rounded-lg">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-medium text-green-700 dark:text-green-300">
{[...comparisonFields].sort((a, b) => b.suitabilityScore - a.suitabilityScore)[0].name}
</p>
<p className="text-xs text-muted-foreground mt-1">
: {[...comparisonFields].sort((a, b) => b.suitabilityScore - a.suitabilityScore)[0].suitabilityScore}
</p>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-medium text-blue-700 dark:text-blue-300">
{[...comparisonFields].sort((a, b) => b.yield - a.yield)[0].name}
</p>
<p className="text-xs text-muted-foreground mt-1">
{[...comparisonFields].sort((a, b) => b.yield - a.yield)[0].yield} kg/
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-950 rounded-lg">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-medium text-purple-700 dark:text-purple-300">
{[...comparisonFields].sort((a, b) => b.organicMatter - a.organicMatter)[0].name}
</p>
<p className="text-xs text-muted-foreground mt-1">
{[...comparisonFields].sort((a, b) => b.organicMatter - a.organicMatter)[0].organicMatter} g/kg
</p>
</div>
</div>
</div>
{/* 智能分析结论 */}
<div className="mb-6">
<h4 className="mb-3 flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-yellow-600" />
</h4>
<div className="space-y-3">
{smartAnalyses.map((analysis, index) => (
<div key={index} className="p-4 bg-blue-50 dark:bg-blue-950 border-l-4 border-blue-600 rounded-r-lg">
<p className="text-sm text-blue-900 dark:text-blue-100 flex items-start gap-2">
<ArrowRight className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{analysis}</span>
</p>
</div>
))}
</div>
</div>
{/* 改进建议 */}
<div className="mb-6">
<h4 className="mb-3 flex items-center gap-2">
<Target className="w-4 h-4 text-green-600" />
</h4>
<div className="space-y-3">
{comparisonFields.map(field => {
const suggestions: string[] = [];
if (field.ph < 6.0 || field.ph > 7.5) {
suggestions.push(
field.ph < 6.0
? '施用石灰或碱性肥料提高pH值至6.0-7.5'
: '施用硫磺或酸性肥料降低pH值至6.0-7.5'
);
}
if (field.organicMatter < 25) {
suggestions.push('增施有机肥提高有机质含量至25 g/kg以上');
}
if (field.soilDepth < 60) {
suggestions.push('进行深耕深松,改善土层厚度');
}
if (field.nitrogen < 1.5) {
suggestions.push('适量施用氮肥,提高全氮含量');
}
if (suggestions.length === 0) {
suggestions.push('土壤条件良好,继续保持现有管理措施');
}
return (
<div key={field.id} className="p-4 bg-green-50 dark:bg-green-950 rounded-lg">
<h5 className="text-sm font-medium text-green-800 dark:text-green-200 mb-2">{field.name}</h5>
<ul className="space-y-1">
{suggestions.map((suggestion, i) => (
<li key={i} className="text-sm text-green-700 dark:text-green-300 flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{suggestion}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
{/* 数据表格 */}
<div className="mb-6">
<h4 className="mb-3"></h4>
<div className="overflow-x-auto">
<table className="w-full border">
<thead className="bg-gray-100 dark:bg-gray-900">
<tr>
<th className="px-3 py-2 text-left text-xs border"></th>
<th className="px-3 py-2 text-center text-xs border"></th>
<th className="px-3 py-2 text-center text-xs border">pH</th>
<th className="px-3 py-2 text-center text-xs border"></th>
<th className="px-3 py-2 text-center text-xs border"></th>
<th className="px-3 py-2 text-center text-xs border"></th>
</tr>
</thead>
<tbody>
{comparisonFields.map(field => (
<tr key={field.id} className="border-t">
<td className="px-3 py-2 text-sm border">{field.name}</td>
<td className="px-3 py-2 text-center text-sm border">{field.area}</td>
<td className="px-3 py-2 text-center text-sm border">{field.ph}</td>
<td className="px-3 py-2 text-center text-sm border">{field.organicMatter} g/kg</td>
<td className="px-3 py-2 text-center text-sm border">{field.yield} kg/</td>
<td className="px-3 py-2 text-center text-sm border">
<Badge className={getGradeColor(field.suitabilityGrade)}>
{field.suitabilityGrade}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 图表插入位置 */}
<div className="mb-6 p-4 bg-muted rounded-lg border-2 border-dashed border-gray-300">
<p className="text-sm text-center text-muted-foreground">
📊
</p>
</div>
</Card>
{/* 下载当前报告 */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="mb-2"></h3>
<p className="text-sm text-muted-foreground">
PDF或Word格式
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
size="lg"
className="bg-red-600 hover:bg-red-700"
onClick={onDownloadPDF}
>
<Download className="w-5 h-5 mr-2" />
PDF报告
</Button>
<Button
size="lg"
className="bg-blue-600 hover:bg-blue-700"
onClick={onDownloadWord}
>
<Download className="w-5 h-5 mr-2" />
Word报告
</Button>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-900 dark:text-blue-100">
💡 <strong></strong>
</p>
<ul className="mt-2 text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import { useReducer } from 'react';
import { toast } from 'sonner';
export interface FieldData {
id: string;
name: string;
area: number;
location: string;
soilType: string;
ph: number;
organicMatter: number;
nitrogen: number;
phosphorus: number;
potassium: number;
soilDepth: number;
slope: number;
currentCrop: string;
yield: number;
suitabilityScore: number;
suitabilityGrade: '高度适宜' | '一般适宜' | '不适宜';
irrigation: string;
drainage: string;
}
export interface ComparisonReport {
id: string;
name: string;
generatedTime: string;
comparedFields: string[];
fieldData: FieldData[];
}
export interface ReportComparisonState {
selectedFields: string[];
reportGenerating: boolean;
reportProgress: number;
savedReports: ComparisonReport[];
}
export type ReportComparisonAction =
| { type: 'SET_SELECTED_FIELDS'; payload: string[] }
| { type: 'ADD_FIELD'; payload: string }
| { type: 'REMOVE_FIELD'; payload: string }
| { type: 'SET_REPORT_GENERATING'; payload: boolean }
| { type: 'SET_REPORT_PROGRESS'; payload: number }
| { type: 'ADD_SAVED_REPORT'; payload: ComparisonReport }
| { type: 'DELETE_REPORT'; payload: string };
const fieldsData: FieldData[] = [
{
id: 'field-1',
name: '东区1号地',
area: 50.5,
location: '东经120.15°, 北纬30.25°',
soilType: '壤土',
ph: 6.5,
organicMatter: 32,
nitrogen: 1.8,
phosphorus: 1.2,
potassium: 18,
soilDepth: 85,
slope: 3,
currentCrop: '水稻',
yield: 750,
suitabilityScore: 87,
suitabilityGrade: '高度适宜',
irrigation: '喷灌',
drainage: '良好',
},
{
id: 'field-2',
name: '西区2号地',
area: 45.2,
location: '东经120.12°, 北纬30.28°',
soilType: '粘土',
ph: 7.8,
organicMatter: 22,
nitrogen: 1.3,
phosphorus: 0.9,
potassium: 14,
soilDepth: 55,
slope: 5,
currentCrop: '玉米',
yield: 650,
suitabilityScore: 72,
suitabilityGrade: '一般适宜',
irrigation: '滴灌',
drainage: '中等',
},
{
id: 'field-3',
name: '南区3号地',
area: 38.8,
location: '东经120.18°, 北纬30.22°',
soilType: '砂土',
ph: 8.5,
organicMatter: 15,
nitrogen: 0.8,
phosphorus: 0.6,
potassium: 10,
soilDepth: 42,
slope: 8,
currentCrop: '小麦',
yield: 480,
suitabilityScore: 58,
suitabilityGrade: '不适宜',
irrigation: '漫灌',
drainage: '较差',
},
{
id: 'field-4',
name: '北区4号地',
area: 55.0,
location: '东经120.20°, 北纬30.30°',
soilType: '壤土',
ph: 6.8,
organicMatter: 28,
nitrogen: 1.6,
phosphorus: 1.0,
potassium: 16,
soilDepth: 75,
slope: 2,
currentCrop: '大豆',
yield: 380,
suitabilityScore: 82,
suitabilityGrade: '高度适宜',
irrigation: '喷灌',
drainage: '良好',
},
];
const initialState: ReportComparisonState = {
selectedFields: ['field-1', 'field-2'],
reportGenerating: false,
reportProgress: 0,
savedReports: [
{
id: 'report-1',
name: '东区1号地 vs 西区2号地对比分析',
generatedTime: '2024-10-15 14:35:22',
comparedFields: ['东区1号地', '西区2号地'],
fieldData: [],
},
{
id: 'report-2',
name: '东区1号地 vs 西区2号地 vs 南区3号地对比分析',
generatedTime: '2024-10-14 09:20:15',
comparedFields: ['东区1号地', '西区2号地', '南区3号地'],
fieldData: [],
},
],
};
export function reportComparisonReducer(
state: ReportComparisonState = initialState,
action: ReportComparisonAction
): ReportComparisonState {
switch (action.type) {
case 'SET_SELECTED_FIELDS':
return {
...state,
selectedFields: action.payload,
};
case 'ADD_FIELD':
if (state.selectedFields.length >= 4) {
toast.error('最多只能同时对比4个地块');
return state;
}
return {
...state,
selectedFields: [...state.selectedFields, action.payload],
};
case 'REMOVE_FIELD':
if (state.selectedFields.length <= 2) {
toast.error('至少需要选择2个地块进行对比');
return state;
}
return {
...state,
selectedFields: state.selectedFields.filter(id => id !== action.payload),
};
case 'SET_REPORT_GENERATING':
return {
...state,
reportGenerating: action.payload,
};
case 'SET_REPORT_PROGRESS':
return {
...state,
reportProgress: action.payload,
};
case 'ADD_SAVED_REPORT':
return {
...state,
savedReports: [action.payload, ...state.savedReports],
};
case 'DELETE_REPORT':
return {
...state,
savedReports: state.savedReports.filter(r => r.id !== action.payload),
};
default:
return state;
}
}
export { fieldsData, initialState };

View File

@@ -1,18 +1,307 @@
'use client';
import { Card } from '@/components/ui/card';
import { useReducer } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { FileText, Download } from 'lucide-react';
import {
reportComparisonReducer,
initialState,
ReportComparisonState,
ComparisonReport,
fieldsData
} from './components/reportComparisonReducer';
import { FieldSelector } from './components/FieldSelector';
import { ReportList } from './components/ReportList';
import { ReportPreview } from './components/ReportPreview';
export default function ReportPage() {
const [state, dispatch] = useReducer(reportComparisonReducer, initialState);
const comparisonFields = fieldsData.filter(f => state.selectedFields.includes(f.id));
const handleAddField = (fieldId: string) => {
dispatch({ type: 'ADD_FIELD', payload: fieldId });
};
const handleRemoveField = (fieldId: string) => {
dispatch({ type: 'REMOVE_FIELD', payload: fieldId });
};
const handleGenerateReport = () => {
dispatch({ type: 'SET_REPORT_GENERATING', payload: true });
dispatch({ type: 'SET_REPORT_PROGRESS', payload: 0 });
let progress = 0;
const interval = setInterval(() => {
progress += 10;
dispatch({ type: 'SET_REPORT_PROGRESS', payload: progress });
if (progress >= 100) {
clearInterval(interval);
dispatch({ type: 'SET_REPORT_GENERATING', payload: false });
// 保存新报告
const reportName = comparisonFields.map(f => f.name).join(' vs ') + '对比分析';
const newReport: ComparisonReport = {
id: `report-${Date.now()}`,
name: reportName,
generatedTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).replace(/\//g, '-'),
comparedFields: comparisonFields.map(f => f.name),
fieldData: comparisonFields,
};
dispatch({ type: 'ADD_SAVED_REPORT', payload: newReport });
toast.success('对比分析报告已生成!');
}
}, 200);
};
const handleDownloadPDF = async (report?: ComparisonReport) => {
const reportData = report || {
name: comparisonFields.map(f => f.name).join(' vs ') + '对比分析',
generatedTime: new Date().toLocaleString('zh-CN'),
comparedFields: comparisonFields.map(f => f.name),
fieldData: comparisonFields,
};
toast.info('正在生成PDF报告请稍候...');
try {
// 简化的PDF生成逻辑实际项目中可使用jsPDF等库
const pdfContent = generatePDFContent(reportData);
downloadFile(pdfContent, `${reportData.name}_${reportData.generatedTime.replace(/:/g, '-').replace(/\//g, '-')}.pdf`, 'application/pdf');
toast.success('PDF报告已下载');
} catch (error) {
console.error('PDF生成失败:', error);
toast.error('PDF生成失败请重试');
}
};
const handleDownloadWord = async (report?: ComparisonReport) => {
const reportData = report || {
name: comparisonFields.map(f => f.name).join(' vs ') + '对比分析',
generatedTime: new Date().toLocaleString('zh-CN'),
comparedFields: comparisonFields.map(f => f.name),
fieldData: comparisonFields,
};
toast.info('正在生成Word报告请稍候...');
try {
const fields = reportData.fieldData.length > 0 ? reportData.fieldData : comparisonFields;
const bestField = [...fields].sort((a, b) => b.suitabilityScore - a.suitabilityScore)[0];
const highestYield = [...fields].sort((a, b) => b.yield - a.yield)[0];
const bestOrganic = [...fields].sort((a, b) => b.organicMatter - a.organicMatter)[0];
// 创建HTML格式的Word文档
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: "Microsoft YaHei", Arial, sans-serif; padding: 40px; }
h1 { text-align: center; color: #2d5016; font-size: 24px; margin-bottom: 30px; }
h2 { color: #3f7f1f; font-size: 18px; margin-top: 30px; margin-bottom: 15px; border-bottom: 2px solid #3f7f1f; padding-bottom: 5px; }
.info { margin: 20px 0; color: #666; font-size: 14px; }
.summary { background: #f0f9ff; padding: 20px; border-left: 4px solid #3f7f1f; margin: 20px 0; }
.summary-item { margin: 10px 0; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #3f7f1f; color: white; }
tr:nth-child(even) { background-color: #f9f9f9; }
.analysis { background: #e8f5e9; padding: 15px; margin: 10px 0; border-radius: 4px; }
.footer { margin-top: 40px; text-align: center; color: #999; font-size: 12px; }
</style>
</head>
<body>
<h1>地块对比分析报告</h1>
<div class="info">
<p><strong>生成时间:</strong>${reportData.generatedTime}</p>
<p><strong>对比地块:</strong>${reportData.comparedFields.join('、')}</p>
</div>
<h2>执行摘要</h2>
<div class="summary">
<div class="summary-item">
<strong>最优地块:</strong>${bestField?.name || 'N/A'}
<span style="color: #3f7f1f;">(综合评分: ${bestField?.suitabilityScore || 0}</span>
</div>
<div class="summary-item">
<strong>最高产量:</strong>${highestYield?.name || 'N/A'}
<span style="color: #3f7f1f;">${highestYield?.yield || 0} kg/亩)</span>
</div>
<div class="summary-item">
<strong>最佳有机质:</strong>${bestOrganic?.name || 'N/A'}
<span style="color: #3f7f1f;">${bestOrganic?.organicMatter || 0} g/kg</span>
</div>
</div>
<h2>详细数据对比</h2>
<table>
<thead>
<tr>
<th>地块名称</th>
<th>面积(亩)</th>
<th>土壤类型</th>
<th>pH值</th>
<th>有机质(g/kg)</th>
<th>产量(kg/亩)</th>
<th>适宜性</th>
</tr>
</thead>
<tbody>
${fields.map(field => `
<tr>
<td>${field.name}</td>
<td>${field.area}</td>
<td>${field.soilType}</td>
<td>${field.ph}</td>
<td>${field.organicMatter}</td>
<td>${field.yield}</td>
<td>${field.suitabilityGrade}</td>
</tr>
`).join('')}
</tbody>
</table>
<h2>智能分析结论</h2>
<div class="analysis">
<p>• 根据对比分析,<strong>${bestField?.name}</strong>综合适宜性最高,建议优先发展。</p>
<p>• <strong>${highestYield?.name}</strong>产量表现最优,可作为高产示范田。</p>
<p>• <strong>${bestOrganic?.name}</strong>有机质含量最佳,土壤肥力较好。</p>
</div>
<div class="footer">
<p>智慧农业生产管理系统 - 地块对比分析报告</p>
<p>本报告由系统自动生成</p>
</div>
</body>
</html>
`;
const blob = new Blob(['\ufeff', htmlContent], {
type: 'application/msword'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${reportData.name}_${reportData.generatedTime.replace(/:/g, '-').replace(/\//g, '-')}.doc`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Word报告已下载');
} catch (error) {
console.error('Word生成失败:', error);
toast.error('Word生成失败请重试');
}
};
const handleDeleteReport = (reportId: string) => {
dispatch({ type: 'DELETE_REPORT', payload: reportId });
toast.success('报告已删除');
};
const generatePDFContent = (reportData: any) => {
// 简化的PDF内容生成
const fields = reportData.fieldData.length > 0 ? reportData.fieldData : comparisonFields;
const bestField = [...fields].sort((a, b) => b.suitabilityScore - a.suitabilityScore)[0];
const highestYield = [...fields].sort((a, b) => b.yield - a.yield)[0];
return `
地块对比分析报告
================
生成时间: ${reportData.generatedTime}
对比地块: ${reportData.comparedFields.join('、')}
执行摘要
--------
最优地块: ${bestField?.name || 'N/A'} (综合评分: ${bestField?.suitabilityScore || 0})
最高产量: ${highestYield?.name || 'N/A'} (${highestYield?.yield || 0} kg/亩)
详细数据
--------
${fields.map(field => `
${field.name}:
- 面积: ${field.area}
- pH值: ${field.ph}
- 有机质: ${field.organicMatter} g/kg
- 产量: ${field.yield} kg/亩
- 适宜性: ${field.suitabilityGrade}
`).join('\n')}
分析结论
--------
根据对比分析,${bestField?.name}综合适宜性最高,建议优先发展。
${highestYield?.name}产量表现最优,可作为高产示范田。
`.trim();
};
const downloadFile = (content: string, filename: string, contentType: string) => {
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/comparison/report
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
</Card>
<div className="flex gap-2">
<Button variant="outline" onClick={() => handleDownloadPDF()}>
<FileText className="w-4 h-4 mr-2" />
PDF
</Button>
</div>
</div>
{/* 地块选择器 */}
<FieldSelector
state={state}
onAddField={handleAddField}
onRemoveField={handleRemoveField}
onGenerateReport={handleGenerateReport}
fieldsData={fieldsData}
/>
{/* 历史报告列表 */}
<ReportList
savedReports={state.savedReports}
onDownloadPDF={handleDownloadPDF}
onDownloadWord={handleDownloadWord}
onDeleteReport={handleDeleteReport}
/>
{/* 报告预览 */}
<ReportPreview
comparisonFields={comparisonFields}
onDownloadPDF={() => handleDownloadPDF()}
onDownloadWord={() => handleDownloadWord()}
/>
</div>
);
}

View File

@@ -0,0 +1,306 @@
'use client';
import { Card } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, LineChart, Line } from 'recharts';
import { BarChart3, PieChart as PieChartIcon, Target, TrendingUp } from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface AnalysisChartsProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function AnalysisCharts({ state, dispatch }: AnalysisChartsProps) {
// 准备图表数据
const scoreDistributionData = [
{ range: '0-20分', count: state.analysisResults.filter(r => r.overallScore < 20).length, color: '#dc2626' },
{ range: '20-40分', count: state.analysisResults.filter(r => r.overallScore >= 20 && r.overallScore < 40).length, color: '#ea580c' },
{ range: '40-60分', count: state.analysisResults.filter(r => r.overallScore >= 40 && r.overallScore < 60).length, color: '#f59e0b' },
{ range: '60-80分', count: state.analysisResults.filter(r => r.overallScore >= 60 && r.overallScore < 80).length, color: '#22c55e' },
{ range: '80-100分', count: state.analysisResults.filter(r => r.overallScore >= 80).length, color: '#16a34a' }
].filter(item => item.count > 0);
const factorPerformanceData = state.factors
.filter(f => f.enabled)
.map(factor => ({
name: factor.name,
weight: Math.round(factor.weight * 100),
category: factor.category
}))
.sort((a, b) => b.weight - a.weight);
const categoryPerformanceData = [
{
category: '土壤条件',
avgScore: state.analysisResults.length > 0
? Math.round(state.analysisResults.reduce((sum, r) => sum + r.soilScore, 0) / state.analysisResults.length)
: 0,
weight: Math.round(state.weightConfig.soil * 100)
},
{
category: '气候条件',
avgScore: state.analysisResults.length > 0
? Math.round(state.analysisResults.reduce((sum, r) => sum + r.climateScore, 0) / state.analysisResults.length)
: 0,
weight: Math.round(state.weightConfig.climate * 100)
},
{
category: '地形条件',
avgScore: state.analysisResults.length > 0
? Math.round(state.analysisResults.reduce((sum, r) => sum + r.topographyScore, 0) / state.analysisResults.length)
: 0,
weight: Math.round(state.weightConfig.topography * 100)
},
{
category: '基础设施',
avgScore: state.analysisResults.length > 0
? Math.round(state.analysisResults.reduce((sum, r) => sum + r.infrastructureScore, 0) / state.analysisResults.length)
: 0,
weight: Math.round(state.weightConfig.infrastructure * 100)
}
];
const cropRecommendationData = state.analysisResults
.flatMap(result => result.recommendedCrops.slice(0, 3))
.reduce((acc, rec) => {
const existing = acc.find(item => item.crop === rec.crop.name);
if (existing) {
existing.frequency += 1;
existing.avgScore = Math.round((existing.avgScore + rec.suitabilityScore) / 2);
} else {
acc.push({
crop: rec.crop.name,
frequency: 1,
avgScore: rec.suitabilityScore,
category: rec.crop.category
});
}
return acc;
}, [] as any[])
.sort((a, b) => b.frequency - a.frequency)
.slice(0, 10);
const economicReturnData = state.analysisResults
.map(result => {
const block = state.landBlocks.find(b => b.id === result.blockId);
return {
name: result.blockName,
returnPerAcre: result.economicReturn && block?.area
? Math.round(result.economicReturn / block.area)
: 0,
totalReturn: result.economicReturn || 0,
score: result.overallScore
};
})
.sort((a, b) => b.returnPerAcre - a.returnPerAcre);
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D'];
if (state.analysisResults.length === 0) {
return (
<Card className="p-8 text-center">
<BarChart3 className="w-16 h-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-medium mb-2"></h3>
<p className="text-muted-foreground mb-4">
</p>
</Card>
);
}
return (
<div className="space-y-6">
<Tabs defaultValue="distribution" className="space-y-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="distribution" className="flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="factors" className="flex items-center gap-2">
<Target className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="crops" className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="economic" className="flex items-center gap-2">
<PieChartIcon className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="comparison" className="flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
</TabsTrigger>
</TabsList>
{/* 得分分布 */}
<TabsContent value="distribution" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={scoreDistributionData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="range" />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#8884d8">
{scoreDistributionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={scoreDistributionData}
cx="50%"
cy="50%"
labelLine={false}
label={({ range, count }) => `${range}: ${count}`}
outerRadius={80}
fill="#8884d8"
dataKey="count"
>
{scoreDistributionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</Card>
</div>
</TabsContent>
{/* 因子分析 */}
<TabsContent value="factors" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={factorPerformanceData} layout="horizontal">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="name" type="category" width={80} />
<Tooltip />
<Bar dataKey="weight" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<RadarChart data={categoryPerformanceData}>
<PolarGrid />
<PolarAngleAxis dataKey="category" />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
<Radar name="平均得分" dataKey="avgScore" stroke="#8884d8" fill="#8884d8" fillOpacity={0.6} />
<Radar name="权重" dataKey="weight" stroke="#82ca9d" fill="#82ca9d" fillOpacity={0.6} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</Card>
</div>
</TabsContent>
{/* 作物推荐 */}
<TabsContent value="crops" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={cropRecommendationData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="crop" />
<YAxis />
<Tooltip />
<Bar dataKey="frequency" fill="#00C49F" />
</BarChart>
</ResponsiveContainer>
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={cropRecommendationData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="crop" />
<YAxis />
<Tooltip />
<Bar dataKey="avgScore" fill="#FFBB28" />
</BarChart>
</ResponsiveContainer>
</Card>
</div>
</TabsContent>
{/* 经济效益 */}
<TabsContent value="economic" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={economicReturnData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="returnPerAcre" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={economicReturnData.sort((a, b) => a.score - b.score)}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="score" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="returnPerAcre" stroke="#8884d8" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</Card>
</div>
</TabsContent>
{/* 对比分析 */}
<TabsContent value="comparison" className="space-y-4">
<Card className="p-6">
<h3 className="text-lg font-medium mb-4"></h3>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={state.analysisResults.map(result => ({
name: result.blockName,
综合得分: result.overallScore,
土壤得分: Math.round(result.soilScore),
气候得分: Math.round(result.climateScore),
地形得分: Math.round(result.topographyScore),
基础设施得分: Math.round(result.infrastructureScore)
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="综合得分" fill="#8884d8" />
<Bar dataKey="土壤得分" fill="#00C49F" />
<Bar dataKey="气候得分" fill="#FFBB28" />
<Bar dataKey="地形得分" fill="#FF8042" />
<Bar dataKey="基础设施得分" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,285 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Settings,
Sliders,
BarChart3,
AlertTriangle,
CheckCircle,
TrendingUp,
Info
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface AnalysisControlPanelProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function AnalysisControlPanel({ state, dispatch }: AnalysisControlPanelProps) {
const handleQuickAnalysis = () => {
// 快速分析,使用默认设置
const allBlocks = state.landBlocks.map(b => b.id);
dispatch({ type: 'SET_SELECTED_BLOCKS', payload: allBlocks });
// 触发分析
setTimeout(() => {
const event = new CustomEvent('run-analysis');
window.dispatchEvent(event);
}, 100);
};
const handleResetFilters = () => {
dispatch({ type: 'RESET_FILTERS' });
};
const enabledFactorsCount = state.factors.filter(f => f.enabled).length;
const totalWeight = state.weightConfig.soil + state.weightConfig.climate +
state.weightConfig.topography + state.weightConfig.infrastructure;
// 获取风险等级
const getRiskLevel = () => {
if (state.analysisResults.length === 0) return { level: 'unknown', color: 'gray', text: '未分析' };
const avgScore = state.analysisResults.reduce((sum, r) => sum + r.overallScore, 0) / state.analysisResults.length;
if (avgScore >= 80) return { level: 'low', color: 'green', text: '低风险' };
if (avgScore >= 60) return { level: 'medium', color: 'yellow', text: '中等风险' };
return { level: 'high', color: 'red', text: '高风险' };
};
const riskLevel = getRiskLevel();
return (
<div className="space-y-4">
{/* 分析状态 */}
<Card className="p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<Badge variant={state.selectedBlocks.length > 0 ? 'default' : 'secondary'}>
{state.selectedBlocks.length} / {state.landBlocks.length}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<Badge variant="outline">
{enabledFactorsCount} / {state.factors.length}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<Badge variant={state.analysisResults.length > 0 ? 'default' : 'secondary'}>
{state.analysisResults.length}
</Badge>
</div>
</div>
</Card>
{/* 权重配置 */}
<Card className="p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<Sliders className="w-4 h-4" />
</h3>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm"></span>
<span className="text-sm font-medium">{Math.round(state.weightConfig.soil * 100)}%</span>
</div>
<Progress value={state.weightConfig.soil * 100} className="h-2" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm"></span>
<span className="text-sm font-medium">{Math.round(state.weightConfig.climate * 100)}%</span>
</div>
<Progress value={state.weightConfig.climate * 100} className="h-2" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm"></span>
<span className="text-sm font-medium">{Math.round(state.weightConfig.topography * 100)}%</span>
</div>
<Progress value={state.weightConfig.topography * 100} className="h-2" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm"></span>
<span className="text-sm font-medium">{Math.round(state.weightConfig.infrastructure * 100)}%</span>
</div>
<Progress value={state.weightConfig.infrastructure * 100} className="h-2" />
</div>
</div>
<div className="mt-3 pt-3 border-t text-center">
<span className="text-xs text-muted-foreground">
: {Math.round(totalWeight * 100)}%
{Math.abs(totalWeight - 1) > 0.01 && (
<span className="text-red-500 ml-1">(100%)</span>
)}
</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full mt-3"
onClick={() => dispatch({ type: 'TOGGLE_WEIGHT_DIALOG' })}
>
<Settings className="w-3 h-3 mr-2" />
</Button>
</Card>
{/* 风险评估 */}
<Card className="p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<Badge
variant={riskLevel.level === 'high' ? 'destructive' :
riskLevel.level === 'medium' ? 'default' : 'secondary'}
className="capitalize"
>
{riskLevel.text}
</Badge>
</div>
{state.analysisResults.length > 0 && (
<>
<div className="text-center py-2">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{Math.round(
state.analysisResults.reduce((sum, r) => sum + r.overallScore, 0) /
state.analysisResults.length
)}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground"> (80)</span>
<span className="font-medium">
{state.analysisResults.filter(r => r.overallScore >= 80).length}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground"> (60-79)</span>
<span className="font-medium">
{state.analysisResults.filter(r => r.overallScore >= 60 && r.overallScore < 80).length}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground"> (&lt;60)</span>
<span className="font-medium">
{state.analysisResults.filter(r => r.overallScore < 60).length}
</span>
</div>
</div>
</>
)}
</div>
</Card>
{/* 快速操作 */}
<Card className="p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
</h3>
<div className="space-y-2">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => dispatch({ type: 'TOGGLE_FACTOR_DIALOG' })}
>
<Settings className="w-3 h-3 mr-2" />
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleQuickAnalysis}
disabled={state.landBlocks.length === 0}
>
<BarChart3 className="w-3 h-3 mr-2" />
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleResetFilters}
disabled={state.analysisResults.length === 0}
>
<Info className="w-3 h-3 mr-2" />
</Button>
</div>
</Card>
{/* 分析建议 */}
{state.analysisResults.length > 0 && (
<Card className="p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
</h3>
<div className="space-y-2 text-sm">
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1"></p>
<p className="text-xs">
{(() => {
const cropCounts = state.analysisResults.flatMap(r =>
r.recommendedCrops.slice(0, 2).map(c => c.crop.name)
);
const topCrops = [...new Set(cropCounts)].slice(0, 3);
return topCrops.join('、');
})()}
</p>
</div>
<div className="p-2 bg-green-50 dark:bg-green-950 rounded text-green-800 dark:text-green-200">
<p className="font-medium mb-1"></p>
<p className="text-xs">
{state.analysisResults
.sort((a, b) => b.overallScore - a.overallScore)
.slice(0, 2)
.map(r => r.blockName)
.join('、')}
</p>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,506 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
FileText,
Download,
Calendar,
MapPin,
BarChart3,
Star,
AlertTriangle,
CheckCircle,
TrendingUp,
Users
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface AnalysisReportProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function AnalysisReport({ state, dispatch }: AnalysisReportProps) {
const handleGenerateReport = () => {
// 生成分析报告
const reportTitle = `地块适宜性分析报告_${new Date().toLocaleDateString('zh-CN')}`;
const reportData = generateReportData();
dispatch({
type: 'GENERATE_REPORT',
payload: { title: reportTitle }
});
// 设置当前报告
dispatch({
type: 'SET_CURRENT_REPORT',
payload: reportData
});
};
const generateReportData = () => {
const selectedBlocks = state.landBlocks.filter(b => state.selectedBlocks.includes(b.id));
const selectedResults = state.analysisResults.filter(r => state.selectedBlocks.includes(r.blockId));
// 统计推荐作物
const cropStats = selectedResults
.flatMap(result => result.recommendedCrops.slice(0, 3))
.reduce((acc, rec) => {
const existing = acc.find(item => item.crop === rec.crop.name);
if (existing) {
existing.frequency += 1;
existing.totalScore += rec.suitabilityScore;
existing.avgScore = Math.round(existing.totalScore / existing.frequency);
} else {
acc.push({
crop: rec.crop.name,
category: rec.crop.category,
frequency: 1,
totalScore: rec.suitabilityScore,
avgScore: rec.suitabilityScore,
economicValue: rec.crop.economicValue
});
}
return acc;
}, [] as any[])
.sort((a, b) => b.frequency - a.frequency);
// 风险统计
const riskStats = {
low: selectedResults.filter(r => r.overallScore >= 80).length,
medium: selectedResults.filter(r => r.overallScore >= 60 && r.overallScore < 80).length,
high: selectedResults.filter(r => r.overallScore < 60).length
};
return {
id: Date.now().toString(),
title: `地块适宜性分析报告_${new Date().toLocaleDateString('zh-CN')}`,
createdAt: new Date().toLocaleString('zh-CN'),
landBlocks: selectedBlocks,
results: selectedResults,
factors: state.factors.filter(f => f.enabled),
weightConfig: state.weightConfig,
summary: {
totalBlocks: selectedBlocks.length,
averageScore: selectedResults.length > 0
? Math.round(selectedResults.reduce((sum, r) => sum + r.overallScore, 0) / selectedResults.length)
: 0,
topRecommendedCrops: cropStats.slice(0, 5),
riskDistribution: riskStats,
totalArea: selectedBlocks.reduce((sum, b) => sum + b.area, 0),
estimatedProduction: selectedResults.reduce((sum, r) => sum + (r.estimatedYield || 0), 0),
estimatedRevenue: selectedResults.reduce((sum, r) => sum + (r.economicReturn || 0), 0)
}
};
};
const handleDownloadReport = () => {
const report = state.currentReport || generateReportData();
// 生成HTML报告
const htmlContent = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${report.title}</title>
<style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; line-height: 1.6; }
.header { text-align: center; border-bottom: 2px solid #ccc; padding-bottom: 20px; margin-bottom: 30px; }
.section { margin-bottom: 30px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 15px; background: #f9f9f9; }
.highlight { background: #e8f5e8; border-color: #4caf50; }
.warning { background: #fff3cd; border-color: #ffc107; }
.danger { background: #f8d7da; border-color: #dc3545; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.badge { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 12px; font-weight: bold; }
.badge-success { background: #28a745; color: white; }
.badge-warning { background: #ffc107; color: black; }
.badge-danger { background: #dc3545; color: white; }
.progress { width: 100%; height: 20px; background: #e9ecef; border-radius: 10px; overflow: hidden; }
.progress-bar { height: 100%; background: #28a745; transition: width 0.3s; }
</style>
</head>
<body>
<div class="header">
<h1>${report.title}</h1>
<p>生成时间: ${report.createdAt}</p>
</div>
<div class="section">
<h2>执行摘要</h2>
<div class="grid">
<div class="card highlight">
<h3>分析概览</h3>
<p><strong>地块数量:</strong> ${report.summary.totalBlocks} 个</p>
<p><strong>总面积:</strong> ${report.summary.totalArea} 亩</p>
<p><strong>平均得分:</strong> ${report.summary.averageScore} 分</p>
</div>
<div class="card">
<h3>经济效益</h3>
<p><strong>预估产量:</strong> ${report.summary.estimatedProduction.toLocaleString()} kg</p>
<p><strong>预估收益:</strong> ¥${report.summary.estimatedRevenue.toLocaleString()}</p>
<p><strong>亩均收益:</strong> ¥${Math.round(report.summary.estimatedRevenue / report.summary.totalArea)}</p>
</div>
</div>
</div>
<div class="section">
<h2>风险分布</h2>
<div class="grid">
<div class="card highlight">
<h3>低风险地块</h3>
<p class="badge badge-success">${report.summary.riskDistribution.low} 个</p>
<div class="progress">
<div class="progress-bar" style="width: ${(report.summary.riskDistribution.low / report.summary.totalBlocks * 100)}%"></div>
</div>
</div>
<div class="card warning">
<h3>中等风险地块</h3>
<p class="badge badge-warning">${report.summary.riskDistribution.medium} 个</p>
<div class="progress">
<div class="progress-bar" style="width: ${(report.summary.riskDistribution.medium / report.summary.totalBlocks * 100)}%; background: #ffc107;"></div>
</div>
</div>
<div class="card danger">
<h3>高风险地块</h3>
<p class="badge badge-danger">${report.summary.riskDistribution.high} 个</p>
<div class="progress">
<div class="progress-bar" style="width: ${(report.summary.riskDistribution.high / report.summary.totalBlocks * 100)}%; background: #dc3545;"></div>
</div>
</div>
</div>
</div>
<div class="section">
<h2>推荐作物排名</h2>
<table>
<thead>
<tr>
<th>排名</th>
<th>作物名称</th>
<th>类别</th>
<th>推荐频次</th>
<th>平均适宜性</th>
<th>经济效益</th>
</tr>
</thead>
<tbody>
${report.summary.topRecommendedCrops.map((crop, index) => `
<tr>
<td>${index + 1}</td>
<td><strong>${crop.crop}</strong></td>
<td>${crop.category}</td>
<td>${crop.frequency} 次</td>
<td>${crop.avgScore} 分</td>
<td>¥${crop.economicValue}/亩</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="section">
<h2>详细地块分析</h2>
${report.results.map(result => {
const block = report.landBlocks.find(b => b.id === result.blockId);
return `
<div class="card" style="margin-bottom: 20px;">
<h3>${result.blockName}</h3>
<p><strong>地块信息:</strong> ${block?.area} 亩 | ${block?.soilType} | ${block?.irrigation}</p>
<p><strong>综合得分:</strong> ${result.overallScore} 分</p>
<p><strong>推荐作物:</strong> ${result.recommendedCrops.slice(0, 3).map(c => c.crop.name).join('、')}</p>
<p><strong>风险因素:</strong> ${result.riskFactors.join('') || '无'}</p>
<p><strong>改进建议:</strong> ${result.improvementSuggestions.join('')}</p>
</div>
`;
}).join('')}
</div>
<div class="section">
<h2>分析说明</h2>
<div class="card">
<h3>评价方法</h3>
<p>本次分析采用多因子综合评价法,考虑土壤条件、气候条件、地形条件和基础设施四个维度。</p>
<p><strong>权重配置:</strong></p>
<ul>
<li>土壤条件: ${Math.round(report.weightConfig.soil * 100)}%</li>
<li>气候条件: ${Math.round(report.weightConfig.climate * 100)}%</li>
<li>地形条件: ${Math.round(report.weightConfig.topography * 100)}%</li>
<li>基础设施: ${Math.round(report.weightConfig.infrastructure * 100)}%</li>
</ul>
</div>
</div>
<div class="section" style="text-align: center; color: #666; font-size: 12px;">
<p>本报告由智慧农业生产管理系统自动生成</p>
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
</div>
</body>
</html>
`;
// 下载HTML文件
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${state.currentReport?.title || '分析报告'}.html`;
link.click();
};
const currentReport = state.currentReport || generateReportData();
if (state.analysisResults.length === 0) {
return (
<Card className="p-8 text-center">
<FileText className="w-16 h-16 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-medium mb-2"></h3>
<p className="text-muted-foreground mb-4">
</p>
<Button onClick={() => dispatch({ type: 'SET_ACTIVE_TAB', payload: 'overview' })}>
</Button>
</Card>
);
}
return (
<div className="space-y-6">
{/* 报告操作 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5" />
<h3 className="text-lg font-medium"></h3>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleGenerateReport}>
<FileText className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleDownloadReport}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 报告概览 */}
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h4 className="font-medium">{currentReport.title}</h4>
<Badge variant="outline" className="text-xs">
{currentReport.createdAt}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{currentReport.summary.totalBlocks}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{currentReport.summary.averageScore}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{currentReport.summary.totalArea}
</div>
<div className="text-sm text-muted-foreground">()</div>
</div>
<div className="text-center p-4 bg-orange-50 dark:bg-orange-950 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{Math.round(currentReport.summary.estimatedRevenue / 1000)}K
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
</Card>
{/* 推荐作物排名 */}
<Card className="p-6">
<h4 className="font-medium mb-4 flex items-center gap-2">
<Star className="w-4 h-4 text-yellow-500" />
</h4>
<div className="space-y-3">
{currentReport.summary.topRecommendedCrops.map((crop, index) => (
<div key={crop.crop} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${
index === 0 ? 'bg-yellow-500' :
index === 1 ? 'bg-gray-400' :
index === 2 ? 'bg-orange-600' :
'bg-blue-500'
}`}>
{index + 1}
</div>
<div>
<div className="font-medium">{crop.crop}</div>
<div className="text-sm text-muted-foreground">{crop.category}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{crop.frequency} </div>
</div>
<div className="text-right">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{crop.avgScore} </div>
</div>
<div className="text-right">
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">¥{crop.economicValue}/</div>
</div>
</div>
</div>
))}
</div>
</Card>
{/* 风险分析 */}
<Card className="p-6">
<h4 className="font-medium mb-4 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-orange-500" />
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-green-900 dark:text-green-100"></span>
<Badge className="bg-green-600">{currentReport.summary.riskDistribution.low} </Badge>
</div>
<Progress value={(currentReport.summary.riskDistribution.low / currentReport.summary.totalBlocks) * 100} className="h-2" />
<p className="text-sm text-green-800 dark:text-green-200 mt-2">
80
</p>
</div>
<div className="p-4 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-yellow-900 dark:text-yellow-100"></span>
<Badge className="bg-yellow-600">{currentReport.summary.riskDistribution.medium} </Badge>
</div>
<Progress value={(currentReport.summary.riskDistribution.medium / currentReport.summary.totalBlocks) * 100} className="h-2" />
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-2">
60-79
</p>
</div>
<div className="p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-red-900 dark:text-red-100"></span>
<Badge className="bg-red-600">{currentReport.summary.riskDistribution.high} </Badge>
</div>
<Progress value={(currentReport.summary.riskDistribution.high / currentReport.summary.totalBlocks) * 100} className="h-2" />
<p className="text-sm text-red-800 dark:text-red-200 mt-2">
&lt;60
</p>
</div>
</div>
</Card>
{/* 详细分析结果 */}
<Card className="p-6">
<h4 className="font-medium mb-4 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-blue-500" />
</h4>
<div className="space-y-4">
{currentReport.results.map(result => {
const block = currentReport.landBlocks.find(b => b.id === result.blockId);
return (
<div key={result.blockId} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h5 className="font-medium flex items-center gap-2">
<MapPin className="w-4 h-4 text-blue-500" />
{result.blockName}
</h5>
<Badge variant="outline">
{result.overallScore}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm mb-3">
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1">{block?.area} </span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1">{block?.soilType}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1">{block?.irrigation}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1">¥{result.economicReturn?.toLocaleString()}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-green-600">:</span>
<div className="mt-1">
{result.recommendedCrops.slice(0, 3).map((rec, index) => (
<Badge key={index} variant="outline" className="mr-2 mb-1">
{rec.crop.name} ({rec.suitabilityScore})
</Badge>
))}
</div>
</div>
<div>
<span className="font-medium text-orange-600">:</span>
<div className="mt-1 text-muted-foreground">
{result.riskFactors.length > 0 ? result.riskFactors.join('') : '无'}
</div>
</div>
</div>
</div>
);
})}
</div>
</Card>
{/* 报告说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<FileText className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-2"></p>
<ul className="space-y-1 text-xs">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,334 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Download,
CheckCircle,
AlertTriangle,
Star,
TrendingUp,
BarChart3,
Filter,
ArrowUpDown
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface AnalysisResultsProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function AnalysisResults({ state, dispatch }: AnalysisResultsProps) {
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-green-600';
if (score >= 60) return 'text-yellow-600';
return 'text-red-600';
};
const getGradeLabel = (score: number) => {
if (score >= 80) return '高度适宜';
if (score >= 60) return '一般适宜';
return '不适宜';
};
const getGradeColor = (score: number) => {
if (score >= 80) return 'bg-green-100 text-green-800';
if (score >= 60) return 'bg-yellow-100 text-yellow-800';
return 'bg-red-100 text-red-800';
};
// 筛选结果
const filteredResults = state.analysisResults.filter(result => {
if (state.filters.scoreRange) {
const [min, max] = state.filters.scoreRange;
if (result.overallScore < min || result.overallScore > max) {
return false;
}
}
return true;
});
// 排序结果
const sortedResults = [...filteredResults].sort((a, b) => {
let aValue: number, bValue: number;
switch (state.sortBy) {
case 'name':
aValue = a.blockName.charCodeAt(0);
bValue = b.blockName.charCodeAt(0);
break;
case 'area':
aValue = a.estimatedYield || 0;
bValue = b.estimatedYield || 0;
break;
case 'score':
default:
aValue = a.overallScore;
bValue = b.overallScore;
break;
}
return state.sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
});
const exportResults = () => {
// 导出分析结果为CSV
const headers = [
'地块名称', '综合得分', '适宜性等级', '土壤得分', '气候得分', '地形得分', '基础设施得分',
'推荐作物', '预估产量(kg)', '经济效益(元)', '风险因素', '改进建议'
];
const csvContent = [
headers.join(','),
...sortedResults.map(result => {
const topCrop = result.recommendedCrops[0];
const riskFactors = result.riskFactors.join(';') || '无';
const improvements = result.improvementSuggestions.join(';') || '无';
return [
result.blockName,
result.overallScore,
getGradeLabel(result.overallScore),
Math.round(result.soilScore),
Math.round(result.climateScore),
Math.round(result.topographyScore),
Math.round(result.infrastructureScore),
topCrop ? topCrop.crop.name : '无',
result.estimatedYield?.toLocaleString() || '',
result.economicReturn?.toLocaleString() || '',
riskFactors,
improvements
].join(',');
})
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `地块适宜性评价结果_${new Date().toLocaleDateString('zh-CN')}.csv`;
link.click();
};
if (state.analysisResults.length === 0) {
return null;
}
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-medium flex items-center gap-2">
<Star className="w-5 h-5 text-yellow-500" />
</h3>
<p className="text-sm text-muted-foreground mt-1">
{sortedResults.length}
</p>
</div>
<div className="flex items-center gap-2">
<Select value={state.sortBy} onValueChange={(value: 'score' | 'name' | 'area') =>
dispatch({ type: 'SET_SORT_BY', payload: value })}>
<SelectTrigger className="w-32">
<SelectValue placeholder="排序方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="score"></SelectItem>
<SelectItem value="name"></SelectItem>
<SelectItem value="area"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => dispatch({
type: 'SET_SORT_ORDER',
payload: state.sortOrder === 'asc' ? 'desc' : 'asc'
})}
>
<ArrowUpDown className="w-4 h-4 mr-2" />
{state.sortOrder === 'asc' ? '升序' : '降序'}
</Button>
<Button variant="outline" onClick={exportResults}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 统计摘要 */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{sortedResults.filter(r => r.overallScore >= 80).length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">
{sortedResults.filter(r => r.overallScore >= 60 && r.overallScore < 80).length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-red-50 dark:bg-red-950 rounded-lg">
<div className="text-2xl font-bold text-red-600">
{sortedResults.filter(r => r.overallScore < 60).length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{Math.round(
sortedResults.reduce((sum, r) => sum + r.overallScore, 0) /
sortedResults.length
)}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
{/* 结果表格 */}
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50 dark:bg-gray-900">
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedResults.map((result, index) => {
const topCrop = result.recommendedCrops[0];
return (
<TableRow key={result.blockId} className="hover:bg-gray-50 dark:hover:bg-gray-900">
<TableCell className="font-medium">{result.blockName}</TableCell>
<TableCell className="text-center">
<div className={`text-lg font-bold ${getScoreColor(result.overallScore)}`}>
{result.overallScore}
</div>
<Progress value={result.overallScore} className="w-16 h-2 mt-1" />
</TableCell>
<TableCell className="text-center">
<Badge className={getGradeColor(result.overallScore)}>
{getGradeLabel(result.overallScore)}
</Badge>
</TableCell>
<TableCell className="text-center">
<div className="text-blue-600 font-medium">
{Math.round(result.soilScore)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="text-green-600 font-medium">
{Math.round(result.climateScore)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="text-orange-600 font-medium">
{Math.round(result.topographyScore)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="text-purple-600 font-medium">
{Math.round(result.infrastructureScore)}
</div>
</TableCell>
<TableCell>
{topCrop && (
<div>
<div className="font-medium">{topCrop.crop.name}</div>
<div className="text-xs text-muted-foreground">
{topCrop.crop.category}
</div>
</div>
)}
</TableCell>
<TableCell className="text-center">
<div className="text-sm">
<div>{result.estimatedYield?.toLocaleString()} kg</div>
</div>
</TableCell>
<TableCell className="text-center">
<div className="text-sm">
<div>¥{result.economicReturn?.toLocaleString()}</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-red-600">
{result.riskFactors.length > 0 ? (
<div className="max-w-[180px]">
{result.riskFactors.join('、')}
</div>
) : (
<div className="text-green-600"></div>
)}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-blue-600">
<div className="max-w-[180px]">
{result.improvementSuggestions.join('、')}
</div>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 详细统计信息 */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-3">
<BarChart3 className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<h4 className="font-medium mb-2"></h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h5 className="font-medium text-blue-700 dark:text-blue-300 mb-1"></h5>
<ul className="text-xs space-y-1">
<li> (80-100): {sortedResults.filter(r => r.overallScore >= 80).length} </li>
<li> (60-79): {sortedResults.filter(r => r.overallScore >= 60 && r.overallScore < 80).length} </li>
<li> (0-59): {sortedResults.filter(r => r.overallScore < 60).length} </li>
</ul>
</div>
<div>
<h5 className="font-medium text-blue-700 dark:text-blue-300 mb-1"></h5>
<ul className="text-xs space-y-1">
<li> : {sortedResults.reduce((sum, r) => sum + (r.estimatedYield || 0), 0).toLocaleString()} kg</li>
<li> : ¥{sortedResults.reduce((sum, r) => sum + (r.economicReturn || 0), 0).toLocaleString()}</li>
<li> : {Math.round(sortedResults.reduce((sum, r) => sum + (r.estimatedYield || 0), 0) / sortedResults.length).toLocaleString()} kg</li>
</ul>
</div>
<div>
<h5 className="font-medium text-blue-700 dark:text-blue-300 mb-1"></h5>
<ul className="text-xs space-y-1">
<li> : {Math.round(sortedResults.reduce((sum, r) => sum + r.soilScore, 0) / sortedResults.length)} </li>
<li> : {Math.round(sortedResults.reduce((sum, r) => sum + r.climateScore, 0) / sortedResults.length)} </li>
<li> : {Math.round(sortedResults.reduce((sum, r) => sum + r.topographyScore, 0) / sortedResults.length)} </li>
</ul>
</div>
</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,375 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Play,
Pause,
Square,
RotateCcw,
Clock,
CheckCircle,
XCircle,
Loader2,
Activity,
Database,
TrendingUp
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
import BatchAnalysisManager from './batchAnalysisService';
interface BatchAnalysisPanelProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function BatchAnalysisPanel({ state, dispatch }: BatchAnalysisPanelProps) {
const manager = new BatchAnalysisManager(dispatch);
const handleStartBatchAnalysis = async () => {
if (state.selectedBlocks.length === 0) {
alert('请先选择要分析的地块');
return;
}
const selectedBlocksData = state.landBlocks.filter(block =>
state.selectedBlocks.includes(block.id)
);
const enabledFactors = state.factors.filter(factor => factor.enabled);
try {
await manager.startBatchAnalysis(
`批量分析_${new Date().toLocaleDateString('zh-CN')}`,
selectedBlocksData,
enabledFactors,
state.weightConfig
);
} catch (error) {
console.error('启动批量分析失败:', error);
alert('启动批量分析失败: ' + (error instanceof Error ? error.message : '未知错误'));
}
};
const handlePauseAnalysis = () => {
manager.pauseAnalysis();
};
const handleResumeAnalysis = () => {
manager.resumeAnalysis();
};
const handleCancelAnalysis = () => {
if (confirm('确定要取消当前的分析任务吗?')) {
manager.cancelAnalysis();
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'running':
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
case 'paused':
return <Pause className="w-4 h-4 text-orange-500" />;
case 'completed':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'failed':
return <XCircle className="w-4 h-4 text-red-500" />;
default:
return <Activity className="w-4 h-4 text-gray-500" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'pending':
return '等待开始';
case 'running':
return '正在分析';
case 'paused':
return '已暂停';
case 'completed':
return '已完成';
case 'failed':
return '分析失败';
default:
return '未知状态';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'bg-yellow-50 border-yellow-200 dark:bg-yellow-950 dark:border-yellow-800';
case 'running':
return 'bg-blue-50 border-blue-200 dark:bg-blue-950 dark:border-blue-800';
case 'paused':
return 'bg-orange-50 border-orange-200 dark:bg-orange-950 dark:border-orange-800';
case 'completed':
return 'bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800';
case 'failed':
return 'bg-red-50 border-red-200 dark:bg-red-950 dark:border-red-800';
default:
return 'bg-gray-50 border-gray-200 dark:bg-gray-950 dark:border-gray-800';
}
};
const currentTask = state.currentTask;
return (
<div className="space-y-6">
{/* 批量分析控制面板 */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium flex items-center gap-2">
<Database className="w-5 h-5" />
</h3>
<Badge variant="outline">
{state.selectedBlocks.length} / {state.landBlocks.length}
</Badge>
</div>
{/* 任务状态显示 */}
{currentTask && (
<div className={`p-4 rounded-lg border mb-6 ${getStatusColor(currentTask.status)}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{getStatusIcon(currentTask.status)}
<h4 className="font-medium">{currentTask.name}</h4>
</div>
<Badge variant="outline">
{getStatusText(currentTask.status)}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm mb-4">
<div>
<span className="text-muted-foreground">:</span>
<div className="font-medium">{currentTask.createdAt}</div>
</div>
<div>
<span className="text-muted-foreground">:</span>
<div className="font-medium">{currentTask.totalBlocks} </div>
</div>
<div>
<span className="text-muted-foreground">:</span>
<div className="font-medium">{currentTask.processedBlocks} </div>
</div>
<div>
<span className="text-muted-foreground">:</span>
<div className="font-medium text-green-600">
{currentTask.totalBlocks > 0
? Math.round((currentTask.successCount / currentTask.processedBlocks) * 100)
: 0}%
</div>
</div>
</div>
{/* 进度条 */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span>{currentTask.progress}%</span>
</div>
<Progress value={currentTask.progress} className="h-2" />
</div>
{/* 当前处理的地块 */}
{currentTask.currentProcessingBlock && (
<div className="mt-3 text-sm">
<span className="text-muted-foreground">: </span>
<span className="font-medium">{currentTask.currentProcessingBlock}</span>
</div>
)}
{/* 错误信息 */}
{currentTask.errorMessage && (
<div className="mt-3 text-sm text-red-600">
: {currentTask.errorMessage}
</div>
)}
</div>
)}
{/* 控制按钮 */}
<div className="flex items-center gap-3">
{!state.isBatchAnalyzing && !currentTask && (
<Button
onClick={handleStartBatchAnalysis}
disabled={state.selectedBlocks.length === 0}
className="flex items-center gap-2"
>
<Play className="w-4 h-4" />
</Button>
)}
{state.isBatchAnalyzing && currentTask && (
<>
<Button
variant="outline"
onClick={handlePauseAnalysis}
className="flex items-center gap-2"
>
<Pause className="w-4 h-4" />
</Button>
<Button
variant="outline"
onClick={handleCancelAnalysis}
className="flex items-center gap-2"
>
<Square className="w-4 h-4" />
</Button>
</>
)}
{currentTask && currentTask.status === 'paused' && (
<>
<Button
onClick={handleResumeAnalysis}
className="flex items-center gap-2"
>
<Play className="w-4 h-4" />
</Button>
<Button
variant="outline"
onClick={handleCancelAnalysis}
className="flex items-center gap-2"
>
<Square className="w-4 h-4" />
</Button>
</>
)}
{currentTask && currentTask.status === 'completed' && (
<Button
variant="outline"
onClick={handleStartBatchAnalysis}
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
</Button>
)}
</div>
</Card>
{/* 分析配置信息 */}
<Card className="p-6">
<h4 className="font-medium mb-4 flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h5 className="text-sm font-medium mb-2"></h5>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span>:</span>
<span>{Math.round(state.weightConfig.soil * 100)}%</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{Math.round(state.weightConfig.climate * 100)}%</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{Math.round(state.weightConfig.topography * 100)}%</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{Math.round(state.weightConfig.infrastructure * 100)}%</span>
</div>
</div>
</div>
<div>
<h5 className="text-sm font-medium mb-2"></h5>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span>:</span>
<span>{state.factors.filter(f => f.enabled).length} / {state.factors.length}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{state.factors.filter(f => f.enabled && f.category === 'soil').length} </span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{state.factors.filter(f => f.enabled && f.category === 'climate').length} </span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{state.factors.filter(f => f.enabled && f.category === 'topography').length} </span>
</div>
</div>
</div>
</div>
</Card>
{/* 地块分析状态概览 */}
{state.landBlocks.some(block => block.analysisStatus) && (
<Card className="p-6">
<h4 className="font-medium mb-4 flex items-center gap-2">
<Activity className="w-4 h-4" />
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{state.landBlocks.filter(b => b.analysisStatus === 'pending').length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{state.landBlocks.filter(b => b.analysisStatus === 'analyzing').length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{state.landBlocks.filter(b => b.analysisStatus === 'completed').length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{state.landBlocks.filter(b => b.analysisStatus === 'failed').length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
</Card>
)}
{/* 使用说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<Database className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-2"></p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: 0-100</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
</ul>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,411 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { toast } from 'sonner';
import {
Database,
CheckCircle2,
AlertTriangle,
Target,
Droplet,
Cloud,
Sun,
ThermometerSun,
Zap,
RefreshCw,
Play,
Pause,
Square
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction, AnalysisResult } from './spatialAnalysisReducer';
import { useState, useEffect } from 'react';
import BatchAnalysisManager from './batchAnalysisService';
interface BatchEvaluationPanelProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function BatchEvaluationPanel({ state, dispatch }: BatchEvaluationPanelProps) {
const [batchManager, setBatchManager] = useState<BatchAnalysisManager | null>(null);
useEffect(() => {
setBatchManager(new BatchAnalysisManager(dispatch));
}, [dispatch]);
const totalWeight = Math.round(
(state.weightConfig.soil + state.weightConfig.climate +
state.weightConfig.topography + state.weightConfig.infrastructure) * 100
);
const handleStartBatchAnalysis = async () => {
if (totalWeight !== 100) {
toast.error(`权重总和必须为100%才能进行批量分析(当前:${totalWeight}%`);
return;
}
const enabledFactors = state.factors.filter(f => f.enabled);
if (enabledFactors.length < 3) {
toast.error('至少需要启用3个评价因子才能进行批量分析');
return;
}
if (!batchManager) {
toast.error('批量分析服务未初始化');
return;
}
try {
// 创建批量分析任务
const taskName = `地块适宜性批量分析_${new Date().toLocaleString('zh-CN')}`;
// 固定分析68个地块
const totalFields = 68;
const blockIds = Array.from({ length: totalFields }, (_, i) => `field-${i + 1}`);
// 开始批量分析
await batchManager.startBatchAnalysis(taskName, enabledFactors, state.weightConfig);
toast.success('批量分析任务已启动,正在处理地块数据...');
} catch (error) {
console.error('启动批量分析失败:', error);
toast.error('启动批量分析失败,请重试');
}
};
const handlePauseAnalysis = () => {
if (batchManager && state.currentTask) {
batchManager.pauseAnalysis();
toast.info('批量分析已暂停');
}
};
const handleResumeAnalysis = () => {
if (batchManager && state.currentTask) {
batchManager.resumeAnalysis();
toast.info('批量分析已恢复');
}
};
const handleCancelAnalysis = () => {
if (batchManager && state.currentTask) {
batchManager.cancelAnalysis();
toast.info('批量分析已取消');
}
};
const currentTask = state.currentTask;
// 计算统计结果
const resultsForStats = state.analysisResults;
const highSuitability = resultsForStats.filter(r => r.overallScore >= 80).length;
const mediumSuitability = resultsForStats.filter(r => r.overallScore >= 60 && r.overallScore < 80).length;
const lowSuitability = resultsForStats.filter(r => r.overallScore < 60).length;
return (
<div className="space-y-6">
{/* 主要批量分析控制面板 */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium flex items-center gap-2">
<Database className="w-5 h-5" />
</h3>
<div className="flex items-center gap-2">
<Badge variant="outline">
68
</Badge>
{currentTask && (
<Badge
variant={
currentTask.status === 'completed' ? 'default' :
currentTask.status === 'running' ? 'secondary' :
currentTask.status === 'failed' ? 'destructive' : 'outline'
}
>
{currentTask.status === 'pending' && '等待中'}
{currentTask.status === 'running' && '分析中'}
{currentTask.status === 'completed' && '已完成'}
{currentTask.status === 'failed' && '失败'}
{currentTask.status === 'paused' && '已暂停'}
{currentTask.status === 'cancelled' && '已取消'}
</Badge>
)}
</div>
</div>
{/* 控制按钮区域 */}
<div className="flex items-center gap-3 mb-6">
{!currentTask || currentTask.status === 'completed' || currentTask.status === 'failed' || currentTask.status === 'cancelled' ? (
<Button
size="lg"
className="bg-green-600 hover:bg-green-700"
onClick={handleStartBatchAnalysis}
disabled={state.isBatchAnalyzing || totalWeight !== 100}
>
<Play className="w-5 h-5 mr-2" />
</Button>
) : (
<>
{currentTask.status === 'running' && (
<Button
size="lg"
variant="outline"
onClick={handlePauseAnalysis}
>
<Pause className="w-5 h-5 mr-2" />
</Button>
)}
{currentTask.status === 'paused' && (
<Button
size="lg"
className="bg-blue-600 hover:bg-blue-700"
onClick={handleResumeAnalysis}
>
<Play className="w-5 h-5 mr-2" />
</Button>
)}
<Button
size="lg"
variant="destructive"
onClick={handleCancelAnalysis}
>
<Square className="w-5 h-5 mr-2" />
</Button>
</>
)}
</div>
{/* 权重和因子验证警告 */}
{totalWeight !== 100 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
100%{totalWeight}%
</p>
</div>
)}
{state.factors.filter(f => f.enabled).length < 3 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
3{state.factors.filter(f => f.enabled).length}
</p>
</div>
)}
{/* 分析进行中的状态显示 */}
{currentTask && (currentTask.status === 'running' || currentTask.status === 'paused') && (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{currentTask.progress}%</span>
</div>
<Progress value={currentTask.progress} className="h-3" />
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="p-3 bg-blue-50 rounded-lg">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-blue-900">{currentTask.currentProcessingBlock || '准备中...'}</p>
</div>
<div className="p-3 bg-blue-50 rounded-lg">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-blue-900">
{currentTask.processedBlocks} / {currentTask.totalBlocks}
</p>
</div>
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-green-900">{currentTask.successCount}</p>
</div>
<div className="p-3 bg-red-50 rounded-lg">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-red-900">{currentTask.failedCount}</p>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p> ...</p>
<p> pH...</p>
<p> ...</p>
<p> ...</p>
</div>
{/* 任务状态信息 */}
<div className="p-3 bg-gray-50 rounded-lg">
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">{currentTask.name}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">{currentTask.createdAt}</span>
</div>
{currentTask.startedAt && (
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">{currentTask.startedAt}</span>
</div>
)}
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">{state.factors.filter(f => f.enabled).length}</span>
</div>
</div>
</div>
</div>
)}
{/* 分析完成状态 */}
{currentTask && currentTask.status === 'completed' && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-sm text-green-800">
{currentTask.totalBlocks}
</p>
<div className="mt-2 text-xs text-green-700">
<div>{currentTask.completedAt}</div>
<div>{currentTask.successCount} </div>
{currentTask.failedCount > 0 && (
<div>{currentTask.failedCount} </div>
)}
</div>
</div>
)}
{/* 分析失败状态 */}
{currentTask && currentTask.status === 'failed' && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-800">
{currentTask.errorMessage || '未知错误'}
</p>
<div className="mt-2 text-xs text-red-700">
<div>{currentTask.processedBlocks} </div>
<div>{currentTask.successCount} </div>
</div>
</div>
)}
</Card>
{/* 批量分析结果统计 */}
{resultsForStats.length > 0 && (
<div className="grid grid-cols-3 gap-4">
<Card className="p-6 bg-gradient-to-br from-green-50 to-green-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-3xl text-green-600">{highSuitability}</p>
<p className="text-xs text-muted-foreground mt-1">
({Math.round((highSuitability / resultsForStats.length) * 100)}%)
</p>
</div>
<CheckCircle2 className="w-12 h-12 text-green-600 opacity-50" />
</div>
</Card>
<Card className="p-6 bg-gradient-to-br from-yellow-50 to-yellow-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-3xl text-yellow-600">{mediumSuitability}</p>
<p className="text-xs text-muted-foreground mt-1">
({Math.round((mediumSuitability / resultsForStats.length) * 100)}%)
</p>
</div>
<AlertTriangle className="w-12 h-12 text-yellow-600 opacity-50" />
</div>
</Card>
<Card className="p-6 bg-gradient-to-br from-red-50 to-red-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-3xl text-red-600">{lowSuitability}</p>
<p className="text-xs text-muted-foreground mt-1">
({Math.round((lowSuitability / resultsForStats.length) * 100)}%)
</p>
</div>
<AlertTriangle className="w-12 h-12 text-red-600 opacity-50" />
</div>
</Card>
</div>
)}
{/* 分析说明卡片 */}
<Card className="p-6">
<h4 className="font-medium mb-4 flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-500" />
</h4>
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-2">
<Target className="w-6 h-6 text-blue-600" />
</div>
<h5 className="font-medium text-sm"></h5>
<p className="text-xs text-muted-foreground mt-1">
pH值
</p>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-2">
<Droplet className="w-6 h-6 text-green-600" />
</div>
<h5 className="font-medium text-sm"></h5>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-2">
<Sun className="w-6 h-6 text-yellow-600" />
</div>
<h5 className="font-medium text-sm"></h5>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-2">
<ThermometerSun className="w-6 h-6 text-purple-600" />
</div>
<h5 className="font-medium text-sm"></h5>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex gap-2">
<Database className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-2"></p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: 68</li>
<li> <strong></strong>: 0-100</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: //</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
</ul>
</div>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,235 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import {
Brain,
Settings,
Eye,
EyeOff,
CheckCircle2,
XCircle,
Target,
Droplet,
Sun,
Mountain,
Building,
Info
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface EvaluationModelProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function EvaluationModel({ state, dispatch }: EvaluationModelProps) {
const handleToggleFactor = (factorId: string) => {
dispatch({ type: 'TOGGLE_FACTOR', payload: factorId });
const factor = state.factors.find(f => f.id === factorId);
if (factor) {
toast.success(`${factor.name}${factor.enabled ? '禁用' : '启用'}`);
}
};
const getFactorIcon = (category: string) => {
const icons = {
soil: <Target className="w-4 h-4" />,
climate: <Sun className="w-4 h-4" />,
topography: <Mountain className="w-4 h-4" />,
infrastructure: <Building className="w-4 h-4" />
};
return icons[category as keyof typeof icons] || <Settings className="w-4 h-4" />;
};
const getFactorCategoryColor = (category: string) => {
const colors = {
soil: 'bg-amber-50 border-amber-200 text-amber-700',
climate: 'bg-blue-50 border-blue-200 text-blue-700',
topography: 'bg-green-50 border-green-200 text-green-700',
infrastructure: 'bg-purple-50 border-purple-200 text-purple-700'
};
return colors[category as keyof typeof colors] || 'bg-gray-50 border-gray-200 text-gray-700';
};
const enabledFactors = state.factors.filter(f => f.enabled);
const enabledFactorCount = enabledFactors.length;
const totalFactorCount = state.factors.length;
const factorCategories = [
{
key: 'soil',
name: '土壤因子',
icon: <Target className="w-5 h-5 text-amber-600" />,
factors: state.factors.filter(f => f.category === 'soil')
},
{
key: 'climate',
name: '气候因子',
icon: <Sun className="w-5 h-5 text-blue-600" />,
factors: state.factors.filter(f => f.category === 'climate')
},
{
key: 'topography',
name: '地形因子',
icon: <Mountain className="w-5 h-5 text-green-600" />,
factors: state.factors.filter(f => f.category === 'topography')
},
{
key: 'infrastructure',
name: '基础设施因子',
icon: <Building className="w-5 h-5 text-purple-600" />,
factors: state.factors.filter(f => f.category === 'infrastructure')
}
];
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Brain className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="px-3 py-1">
{enabledFactorCount}/{totalFactorCount}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => {
// 启用所有因子
state.factors.forEach(factor => {
if (!factor.enabled) {
dispatch({ type: 'TOGGLE_FACTOR', payload: factor.id });
}
});
toast.success('已启用所有评价因子');
}}
>
</Button>
</div>
</div>
{/* 评价因子配置区域 */}
<div className="space-y-6">
{factorCategories.map((category) => (
<div key={category.key} className="space-y-3">
<div className="flex items-center gap-2">
{category.icon}
<h4 className="font-medium">{category.name}</h4>
<Badge variant="outline" className="text-xs">
{category.factors.filter(f => f.enabled).length}/{category.factors.length}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{category.factors.map((factor) => (
<div
key={factor.id}
className={`p-4 rounded-lg border-2 transition-all ${
factor.enabled
? getFactorCategoryColor(factor.category)
: 'bg-gray-50 border-gray-200 text-gray-400 opacity-60'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{getFactorIcon(factor.category)}
<h5 className="font-medium text-sm">{factor.name}</h5>
{factor.enabled ? (
<CheckCircle2 className="w-4 h-4 text-green-600" />
) : (
<XCircle className="w-4 h-4 text-gray-400" />
)}
</div>
<p className="text-xs text-muted-foreground mb-2">
{factor.description}
</p>
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs">
: {Math.round(factor.weight * 100)}%
</Badge>
<Switch
checked={factor.enabled}
onCheckedChange={() => handleToggleFactor(factor.id)}
disabled={state.isBatchAnalyzing}
/>
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* 模型配置说明 */}
<div className="mt-6 p-4 bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<h4 className="font-medium text-purple-800 dark:text-purple-200">
</h4>
<div className="text-sm text-purple-700 dark:text-purple-300 mt-2 space-y-2">
<p>
<strong></strong>(AHP)
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<div>
<h5 className="font-medium mb-2"></h5>
<ul className="text-xs space-y-1">
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> <strong></strong></li>
</ul>
</div>
<div>
<h5 className="font-medium mb-2"></h5>
<ul className="text-xs space-y-1">
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> <strong></strong></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{/* 启用因子过少警告 */}
{enabledFactorCount < 6 && (
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
{enabledFactorCount} 6-8
</p>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,253 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Settings,
Sliders,
CheckCircle,
XCircle,
Info
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface FactorConfigurationProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
isDialog?: boolean;
}
export default function FactorConfiguration({ state, dispatch, isDialog = false }: FactorConfigurationProps) {
const categories = [
{ id: 'soil', name: '土壤条件', color: 'blue' },
{ id: 'climate', name: '气候条件', color: 'green' },
{ id: 'topography', name: '地形条件', color: 'orange' },
{ id: 'infrastructure', name: '基础设施', color: 'purple' }
];
const handleToggleFactor = (factorId: string) => {
dispatch({ type: 'TOGGLE_FACTOR', payload: factorId });
};
const handleUpdateWeight = (factorId: string, weight: number) => {
dispatch({ type: 'UPDATE_FACTOR_WEIGHT', payload: { id: factorId, weight } });
};
const handleResetToDefaults = () => {
// 重置为默认权重
const defaultWeights = {
'soil_ph': 0.15,
'soil_organic_matter': 0.12,
'soil_nutrients': 0.18,
'soil_texture': 0.10,
'temperature': 0.15,
'rainfall': 0.12,
'sunlight': 0.10,
'elevation': 0.08,
'slope': 0.06,
'irrigation': 0.12,
'accessibility': 0.05
};
Object.entries(defaultWeights).forEach(([id, weight]) => {
dispatch({ type: 'UPDATE_FACTOR_WEIGHT', payload: { id, weight } });
});
};
const getTotalWeight = () => {
return state.factors.reduce((sum, factor) => sum + factor.weight, 0);
};
const getCategoryTotal = (categoryId: string) => {
return state.factors
.filter(f => f.category === categoryId && f.enabled)
.reduce((sum, f) => sum + f.weight, 0);
};
const categoryColors = {
soil: 'blue',
climate: 'green',
topography: 'orange',
infrastructure: 'purple'
};
const content = (
<div className="space-y-6">
{/* 权重总览 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<Badge variant={Math.abs(getTotalWeight() - 1) < 0.01 ? 'default' : 'destructive'}>
{Math.round(getTotalWeight() * 100)}%
</Badge>
<Button variant="outline" size="sm" onClick={handleResetToDefaults}>
</Button>
</div>
</div>
<div className="space-y-3">
{categories.map(category => {
const totalWeight = getCategoryTotal(category.id);
return (
<div key={category.id}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{category.name}</span>
<span className="text-sm text-muted-foreground">
{Math.round(totalWeight * 100)}%
</span>
</div>
<Progress
value={totalWeight * 100}
className={`h-2 ${
category.id === 'soil' ? 'bg-blue-100' :
category.id === 'climate' ? 'bg-green-100' :
category.id === 'topography' ? 'bg-orange-100' :
'bg-purple-100'
}`}
/>
</div>
);
})}
</div>
</Card>
{/* 因子详细配置 */}
<div className="space-y-4">
{categories.map(category => (
<Card key={category.id} className="p-4">
<h3 className="font-medium mb-4 flex items-center gap-2">
<div className={`w-3 h-3 rounded-full bg-${categoryColors[category.id]}-500`} />
{category.name}
<Badge variant="outline" className="text-xs">
{state.factors.filter(f => f.category === category.id).length}
</Badge>
</h3>
<div className="space-y-4">
{state.factors
.filter(factor => factor.category === category.id)
.map(factor => (
<div key={factor.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{factor.name}</h4>
{factor.enabled ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-gray-400" />
)}
</div>
<p className="text-sm text-muted-foreground">
{factor.description}
</p>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<div className="text-lg font-bold">
{Math.round(factor.weight * 100)}%
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<Switch
checked={factor.enabled}
onCheckedChange={() => handleToggleFactor(factor.id)}
/>
</div>
</div>
{/* 权重调节 */}
{factor.enabled && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{Math.round(factor.weight * 100)}%</span>
</div>
<Slider
value={[factor.weight * 100]}
onValueChange={([value]) =>
handleUpdateWeight(factor.id, value / 100)
}
max={50}
min={0}
step={1}
className="w-full"
/>
</div>
)}
</div>
))}
</div>
</Card>
))}
</div>
{/* 配置说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-2"></p>
<ul className="space-y-1 text-xs">
<li> 100%</li>
<li> /</li>
<li> </li>
<li> </li>
<li> "重置默认"</li>
</ul>
</div>
</div>
</Card>
</div>
);
if (isDialog) {
return (
<Dialog open={state.showFactorDialog} onOpenChange={() => dispatch({ type: 'TOGGLE_FACTOR_DIALOG' })}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium"></h3>
<div className="flex items-center gap-2">
<Badge variant="outline">
{state.factors.filter(f => f.enabled).length} / {state.factors.length}
</Badge>
<Button variant="outline" size="sm" onClick={handleResetToDefaults}>
</Button>
</div>
</div>
{content}
</div>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { toast } from 'sonner';
import {
Settings,
SlidersHorizontal,
TrendingUp,
Target,
Droplet,
Sun,
Mountain,
Building,
RefreshCw
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface FactorWeightsProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function FactorWeights({ state, dispatch }: FactorWeightsProps) {
const totalWeight = Math.round(
(state.weightConfig.soil + state.weightConfig.climate +
state.weightConfig.topography + state.weightConfig.infrastructure) * 100
);
const handleWeightChange = (category: keyof SpatialAnalysisState['weightConfig'], value: number[]) => {
dispatch({
type: 'SET_WEIGHT_CONFIG',
payload: {
...state.weightConfig,
[category]: value[0]
}
});
};
const handleResetWeights = () => {
dispatch({ type: 'RESET_WEIGHTS' });
toast.success('权重配置已重置为默认值');
};
const getWeightColor = (category: string) => {
const colors = {
soil: 'text-amber-600 bg-amber-50 border-amber-200',
climate: 'text-blue-600 bg-blue-50 border-blue-200',
topography: 'text-green-600 bg-green-50 border-green-200',
infrastructure: 'text-purple-600 bg-purple-50 border-purple-200'
};
return colors[category as keyof typeof colors] || 'text-gray-600 bg-gray-50 border-gray-200';
};
const getWeightIcon = (category: string) => {
const icons = {
soil: <Target className="w-5 h-5" />,
climate: <Sun className="w-5 h-5" />,
topography: <Mountain className="w-5 h-5" />,
infrastructure: <Building className="w-5 h-5" />
};
return icons[category as keyof typeof icons] || <Settings className="w-5 h-5" />;
};
const factorCategories = [
{
key: 'soil' as const,
name: '土壤因子',
icon: <Target className="w-5 h-5 text-amber-600" />,
description: 'pH值、有机质、全氮、全磷、全钾、土壤质地等',
factors: state.factors.filter(f => f.category === 'soil')
},
{
key: 'climate' as const,
name: '气候因子',
icon: <Sun className="w-5 h-5 text-blue-600" />,
description: '年均温度、年降雨量、日照时数、无霜期等',
factors: state.factors.filter(f => f.category === 'climate')
},
{
key: 'topography' as const,
name: '地形因子',
icon: <Mountain className="w-5 h-5 text-green-600" />,
description: '海拔高度、坡度、坡向、地貌类型等',
factors: state.factors.filter(f => f.category === 'topography')
},
{
key: 'infrastructure' as const,
name: '基础设施因子',
icon: <Building className="w-5 h-5 text-purple-600" />,
description: '灌溉条件、交通便利性、排水条件等',
factors: state.factors.filter(f => f.category === 'infrastructure')
}
];
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<SlidersHorizontal className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant={totalWeight === 100 ? "default" : "destructive"}
className="px-3 py-1"
>
: {totalWeight}%
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleResetWeights}
>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 权重配置区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{factorCategories.map((category) => (
<div
key={category.key}
className={`p-4 rounded-lg border-2 ${getWeightColor(category.key)}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{getWeightIcon(category.key)}
<div>
<h4 className="font-medium">{category.name}</h4>
<p className="text-xs text-muted-foreground mt-1">
{category.description}
</p>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold">
{Math.round(state.weightConfig[category.key] * 100)}%
</div>
<div className="text-xs text-muted-foreground">
{category.factors.length}
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span></span>
<span>{Math.round(state.weightConfig[category.key] * 100)}%</span>
</div>
<Slider
value={[state.weightConfig[category.key] * 100]}
onValueChange={(value) => handleWeightChange(category.key, value)}
max={60}
min={5}
step={5}
className="w-full"
disabled={state.isBatchAnalyzing}
/>
<Progress
value={state.weightConfig[category.key] * 100}
className="h-2"
/>
</div>
{/* 显示该类别下的主要因子 */}
<div className="mt-4 pt-4 border-t border-current/20">
<div className="text-xs font-medium mb-2"></div>
<div className="flex flex-wrap gap-1">
{category.factors.slice(0, 3).map((factor) => (
<Badge
key={factor.id}
variant="outline"
className="text-xs px-2 py-0.5"
>
{factor.name}
</Badge>
))}
{category.factors.length > 3 && (
<Badge variant="outline" className="text-xs px-2 py-0.5">
+{category.factors.length - 3}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
{/* 权重总和警告 */}
{totalWeight !== 100 && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-start gap-3">
<TrendingUp className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
100%
{totalWeight}%{totalWeight > 100 ? '超出' : '还差'} {Math.abs(totalWeight - 100)}%
</p>
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
💡 (35%)(30%)(20%)(15%)
</p>
</div>
</div>
</div>
)}
{/* 权重说明 */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-3">
<Settings className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="font-medium text-blue-800 dark:text-blue-200">
</h4>
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-2 space-y-1">
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> <strong></strong></li>
</ul>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-3">
</p>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,299 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { MapPin, CheckCircle, Circle, Loader2, XCircle } from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface LandBlockSelectorProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
isDialog?: boolean;
}
export default function LandBlockSelector({ state, dispatch, isDialog = false }: LandBlockSelectorProps) {
const handleSelectAll = () => {
if (state.selectedBlocks.length === state.landBlocks.length) {
dispatch({ type: 'SET_SELECTED_BLOCKS', payload: [] });
} else {
dispatch({ type: 'SET_SELECTED_BLOCKS', payload: state.landBlocks.map(b => b.id) });
}
};
const getBlockScore = (blockId: string) => {
const result = state.analysisResults.find(r => r.blockId === blockId);
return result?.overallScore || null;
};
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-green-600';
if (score >= 60) return 'text-yellow-600';
return 'text-red-600';
};
const getScoreLabel = (score: number) => {
if (score >= 80) return '优秀';
if (score >= 60) return '良好';
if (score >= 40) return '一般';
return '较差';
};
if (isDialog) {
// 对话框模式下的简化版本
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium"></h3>
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
>
{state.selectedBlocks.length === state.landBlocks.length ? '取消全选' : '全选'}
</Button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{state.landBlocks.map(block => {
const isSelected = state.selectedBlocks.includes(block.id);
const score = getBlockScore(block.id);
return (
<div
key={block.id}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
isSelected ? 'bg-blue-50 border-blue-200' : 'hover:bg-gray-50'
}`}
onClick={() => dispatch({ type: 'TOGGLE_BLOCK_SELECTION', payload: block.id })}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Checkbox checked={isSelected} />
<div>
<p className="font-medium">{block.name}</p>
<p className="text-sm text-muted-foreground">
{block.area} · {block.soilType} · {block.irrigation}
</p>
</div>
</div>
{score && (
<Badge className={getScoreColor(score)}>
{score}
</Badge>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium"></h3>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
>
{state.selectedBlocks.length === state.landBlocks.length ? '取消全选' : '全选'}
</Button>
<Badge variant="secondary">
{state.selectedBlocks.length} / {state.landBlocks.length}
</Badge>
</div>
</div>
{/* 地块列表 */}
<div className="space-y-3">
{state.landBlocks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MapPin className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p></p>
<p className="text-sm mt-1"></p>
</div>
) : (
state.landBlocks.map(block => {
const isSelected = state.selectedBlocks.includes(block.id);
const score = getBlockScore(block.id);
return (
<div
key={block.id}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
isSelected
? 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800'
: 'hover:bg-gray-50 dark:hover:bg-gray-900 border-gray-200 dark:border-gray-700'
}`}
onClick={() => dispatch({ type: 'TOGGLE_BLOCK_SELECTION', payload: block.id })}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="pt-1">
{isSelected ? (
<CheckCircle className="w-5 h-5 text-blue-600" />
) : (
<Circle className="w-5 h-5 text-gray-400" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium">{block.name}</h4>
{block.currentCrop && (
<Badge variant="outline" className="text-xs">
{block.currentCrop}
</Badge>
)}
</div>
{/* 地块基本信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div className="flex items-center gap-1">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{block.area} </span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{block.soilType}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{block.irrigation}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{block.elevation}m</span>
</div>
</div>
{/* 土壤指标 */}
<div className="mt-3 grid grid-cols-2 md:grid-cols-5 gap-3 text-xs">
<div>
<span className="text-muted-foreground">pH:</span>
<span className="ml-1 font-medium">{block.pH}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1 font-medium">{block.organicMatter}%</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1 font-medium">{block.nitrogen}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1 font-medium">{block.phosphorus}</span>
</div>
<div>
<span className="text-muted-foreground">:</span>
<span className="ml-1 font-medium">{block.potassium}</span>
</div>
</div>
</div>
</div>
{/* 分析状态和适宜性指数 */}
{(score || block.analysisStatus) && (
<div className="ml-4 text-center">
{block.analysisStatus === 'analyzing' && (
<div className="flex flex-col items-center">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
<div className="text-xs text-blue-500 mt-1"></div>
</div>
)}
{block.analysisStatus === 'failed' && (
<div className="flex flex-col items-center">
<XCircle className="w-6 h-6 text-red-500" />
<div className="text-xs text-red-500 mt-1"></div>
</div>
)}
{block.analysisStatus === 'completed' && block.suitabilityIndex && (
<div>
<div className={`text-2xl font-bold ${getScoreColor(block.suitabilityIndex)}`}>
{block.suitabilityIndex}
</div>
<div className={`text-xs ${getScoreColor(block.suitabilityIndex)}`}>
</div>
<Progress value={block.suitabilityIndex} className="w-16 h-2 mt-1" />
<div className="text-xs text-muted-foreground mt-1">
{block.lastAnalyzed}
</div>
</div>
)}
{score && !block.analysisStatus && (
<div>
<div className={`text-2xl font-bold ${getScoreColor(score)}`}>
{score}
</div>
<div className={`text-xs ${getScoreColor(score)}`}>
{getScoreLabel(score)}
</div>
<Progress value={score} className="w-16 h-2 mt-1" />
</div>
)}
</div>
)}
</div>
</div>
);
})
)}
</div>
{/* 选中地块统计 */}
{state.selectedBlocks.length > 0 && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="font-medium text-green-900 dark:text-green-100 mb-2"></h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm text-green-800 dark:text-green-200">
<div>
<span className="text-muted-foreground text-green-700 dark:text-green-300">:</span>
<span className="ml-1 font-medium">{state.selectedBlocks.length} </span>
</div>
<div>
<span className="text-muted-foreground text-green-700 dark:text-green-300">:</span>
<span className="ml-1 font-medium">
{state.landBlocks
.filter(b => state.selectedBlocks.includes(b.id))
.reduce((sum, b) => sum + b.area, 0)
.toFixed(1)}
</span>
</div>
<div>
<span className="text-muted-foreground text-green-700 dark:text-green-300">:</span>
<span className="ml-1 font-medium">
{state.analysisResults.length > 0
? Math.round(
state.analysisResults
.filter(r => state.selectedBlocks.includes(r.blockId))
.reduce((sum, r) => sum + r.overallScore, 0) /
state.analysisResults.filter(r => state.selectedBlocks.includes(r.blockId)).length
)
: '--'
}
</span>
</div>
<div>
<span className="text-muted-foreground text-green-700 dark:text-green-300">:</span>
<span className="ml-1 font-medium">
{[...new Set(
state.landBlocks
.filter(b => state.selectedBlocks.includes(b.id))
.map(b => b.soilType)
)].join(', ')}
</span>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
import BatchEvaluationPanel from './BatchEvaluationPanel';
import AnalysisResults from './AnalysisResults';
import FactorWeights from './FactorWeights';
import EvaluationModel from './EvaluationModel';
interface SpatialAnalysisContentProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function SpatialAnalysisContent({ state, dispatch }: SpatialAnalysisContentProps) {
return (
<div className="space-y-6">
{/* 标题区域 */}
<div>
<h2 className="text-xl text-green-800 dark:text-green-200"></h2>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
{/* 权重配置面板 */}
<FactorWeights state={state} dispatch={dispatch} />
{/* 评价模型配置 */}
<EvaluationModel state={state} dispatch={dispatch} />
{/* 批量评价面板 */}
<BatchEvaluationPanel state={state} dispatch={dispatch} />
{/* 分析结果记录 */}
{state.analysisResults.length > 0 && (
<AnalysisResults state={state} dispatch={dispatch} />
)}
</div>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { MapPin, Layers, Navigation } from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface SpatialDistributionProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
}
export default function SpatialDistribution({ state, dispatch }: SpatialDistributionProps) {
const getScoreColor = (score: number) => {
if (score >= 80) return 'bg-green-500';
if (score >= 60) return 'bg-yellow-500';
if (score >= 40) return 'bg-orange-500';
return 'bg-red-500';
};
const getScoreTextColor = (score: number) => {
if (score >= 80) return 'text-green-600';
if (score >= 60) return 'text-yellow-600';
if (score >= 40) return 'text-orange-600';
return 'text-red-600';
};
const selectedBlocksWithScores = state.selectedBlocks.map(blockId => {
const block = state.landBlocks.find(b => b.id === blockId);
const result = state.analysisResults.find(r => r.blockId === blockId);
return { block, result };
}).filter(item => item.block);
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium flex items-center gap-2">
<MapPin className="w-5 h-5" />
</h3>
<Badge variant="outline">
{selectedBlocksWithScores.length}
</Badge>
</div>
{/* 简化的地图展示 */}
<div className="relative bg-gray-50 dark:bg-gray-900 rounded-lg p-8 min-h-[400px] flex items-center justify-center">
{selectedBlocksWithScores.length === 0 ? (
<div className="text-center text-muted-foreground">
<MapPin className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p></p>
</div>
) : (
<div className="relative w-full h-full min-h-[350px]">
{/* 模拟地图背景 */}
<div className="absolute inset-0 bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950 rounded-lg opacity-30"></div>
{/* 地块分布 */}
<div className="relative h-full flex items-center justify-center">
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 w-full max-w-2xl">
{selectedBlocksWithScores.map((item, index) => {
const positions = [
{ top: '20%', left: '15%' },
{ top: '25%', left: '60%' },
{ top: '60%', left: '25%' },
{ top: '65%', left: '70%' },
{ top: '40%', left: '40%' },
{ top: '30%', left: '80%' }
];
const position = positions[index % positions.length];
return (
<div
key={item.block!.id}
className="absolute transform -translate-x-1/2 -translate-y-1/2"
style={position}
>
{/* 地块标记 */}
<div className="relative">
<div
className={`w-12 h-12 ${getScoreColor(item.result?.overallScore || 0)} rounded-full opacity-80 animate-pulse`}></div>
<div className="absolute inset-0 flex items-center justify-center">
<MapPin className="w-6 h-6 text-white" />
</div>
{/* 地块信息卡片 */}
<div className="absolute top-14 left-1/2 transform -translate-x-1/2 bg-white dark:bg-gray-800 border rounded-lg shadow-lg p-3 w-48 z-10">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-sm truncate">{item.block!.name}</h4>
<Badge
variant="outline"
className={`text-xs ${getScoreTextColor(item.result?.overallScore || 0)}`}
>
{item.result?.overallScore || '--'}
</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>:</span>
<span>{item.block!.area} </span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{item.block!.soilType}</span>
</div>
{item.result && (
<>
<div className="flex justify-between">
<span>:</span>
<span>{Math.round(item.result.soilScore)}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{Math.round(item.result.climateScore)}</span>
</div>
</>
)}
{item.result?.recommendedCrops[0] && (
<div className="pt-1 border-t">
<span className="text-blue-600">: {item.result.recommendedCrops[0].crop.name}</span>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 图例 */}
<div className="absolute bottom-4 left-4 bg-white dark:bg-gray-800 border rounded-lg p-3 shadow-lg">
<h4 className="font-medium text-sm mb-2 flex items-center gap-2">
<Layers className="w-4 h-4" />
</h4>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span> (80)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<span> (60-79)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-orange-500 rounded-full"></div>
<span> (40-59)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span> (&lt;40)</span>
</div>
</div>
</div>
{/* 指北针 */}
<div className="absolute top-4 right-4">
<div className="w-12 h-12 bg-white dark:bg-gray-800 border rounded-full shadow-lg flex items-center justify-center">
<Navigation className="w-6 h-6 text-gray-600 dark:text-gray-400" />
</div>
</div>
</div>
)}
</div>
{/* 统计信息 */}
{selectedBlocksWithScores.length > 0 && (
<div className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{selectedBlocksWithScores.filter(item => (item.result?.overallScore || 0) >= 80).length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{Math.round(
selectedBlocksWithScores.reduce((sum, item) => sum + (item.block?.area || 0), 0)
)}
</div>
<div className="text-sm text-muted-foreground">()</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{selectedBlocksWithScores.length > 0
? Math.round(
selectedBlocksWithScores.reduce((sum, item) => sum + (item.result?.overallScore || 0), 0) /
selectedBlocksWithScores.length
)
: 0}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{[...new Set(
selectedBlocksWithScores
.filter(item => item.result?.recommendedCrops[0])
.map(item => item.result!.recommendedCrops[0].crop.name)
)].length}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,299 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Sliders,
RotateCcw,
Info,
Scale
} from 'lucide-react';
import { SpatialAnalysisState, SpatialAnalysisAction } from './spatialAnalysisReducer';
interface WeightConfigurationProps {
state: SpatialAnalysisState;
dispatch: React.Dispatch<SpatialAnalysisAction>;
isDialog?: boolean;
}
export default function WeightConfiguration({ state, dispatch, isDialog = false }: WeightConfigurationProps) {
const categories = [
{ id: 'soil', name: '土壤条件', icon: '🌱', color: 'blue' },
{ id: 'climate', name: '气候条件', icon: '🌤️', color: 'green' },
{ id: 'topography', name: '地形条件', icon: '⛰️', color: 'orange' },
{ id: 'infrastructure', name: '基础设施', icon: '🏗️', color: 'purple' }
];
const handleUpdateWeight = (category: string, value: number) => {
const newWeightConfig = {
...state.weightConfig,
[category]: value / 100
};
dispatch({ type: 'SET_WEIGHT_CONFIG', payload: newWeightConfig });
};
const handleResetToDefaults = () => {
const defaultConfig = {
soil: 0.35,
climate: 0.30,
topography: 0.20,
infrastructure: 0.15
};
dispatch({ type: 'SET_WEIGHT_CONFIG', payload: defaultConfig });
};
const handleBalanceWeights = () => {
// 自动平衡权重确保总和为100%
const total = Object.values(state.weightConfig).reduce((sum, val) => sum + val, 0);
if (total > 0) {
const balancedConfig = Object.fromEntries(
Object.entries(state.weightConfig).map(([key, value]) => [key, value / total])
) as typeof state.weightConfig;
dispatch({ type: 'SET_WEIGHT_CONFIG', payload: balancedConfig });
}
};
const getTotalWeight = () => {
return Object.values(state.weightConfig).reduce((sum, val) => sum + val, 0);
};
const categoryConfigs = categories.map(category => ({
...category,
weight: state.weightConfig[category.id as keyof typeof state.weightConfig],
percentage: Math.round(state.weightConfig[category.id as keyof typeof state.weightConfig] * 100)
}));
const content = (
<div className="space-y-6">
{/* 权重总览 */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium flex items-center gap-2">
<Scale className="w-5 h-5" />
</h3>
<div className="flex items-center gap-3">
<div className="text-right">
<div className={`text-2xl font-bold ${
Math.abs(getTotalWeight() - 1) < 0.01 ? 'text-green-600' : 'text-red-600'
}`}>
{Math.round(getTotalWeight() * 100)}%
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<Button variant="outline" size="sm" onClick={handleResetToDefaults}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" size="sm" onClick={handleBalanceWeights}>
<Sliders className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 权重可视化 */}
<div className="space-y-4">
{categoryConfigs.map(category => (
<div key={category.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{category.icon}</span>
<span className="font-medium">{category.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-bold">{category.percentage}%</span>
<Badge
variant="outline"
className={
category.id === 'soil' ? 'border-blue-200 text-blue-600' :
category.id === 'climate' ? 'border-green-200 text-green-600' :
category.id === 'topography' ? 'border-orange-200 text-orange-600' :
'border-purple-200 text-purple-600'
}
>
{category.weight.toFixed(2)}
</Badge>
</div>
</div>
<Progress
value={category.weight * 100}
className={`h-3 ${
category.id === 'soil' ? 'bg-blue-100' :
category.id === 'climate' ? 'bg-green-100' :
category.id === 'topography' ? 'bg-orange-100' :
'bg-purple-100'
}`}
/>
</div>
))}
{Math.abs(getTotalWeight() - 1) > 0.01 && (
<div className="pt-2 border-t">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="text-red-600 font-medium">
{Math.abs((getTotalWeight() - 1) * 100).toFixed(1)}%
</span>
</div>
<Progress value={getTotalWeight() * 100} className="h-2 mt-1" />
<p className="text-xs text-red-600 mt-1">
100%"自动平衡"
</p>
</div>
)}
</div>
</Card>
{/* 权重调节面板 */}
<Card className="p-6">
<h3 className="text-lg font-medium mb-6"></h3>
<div className="space-y-6">
{categoryConfigs.map(category => (
<div key={category.id} className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl">{category.icon}</span>
<div>
<h4 className="font-medium">{category.name}</h4>
<p className="text-sm text-muted-foreground">
{category.id === 'soil' && '包括土壤pH、有机质、养分含量等指标'}
{category.id === 'climate' && '包括温度、降水、光照等气象条件'}
{category.id === 'topography' && '包括海拔、坡度等地形特征'}
{category.id === 'infrastructure' && '包括灌溉、交通等设施条件'}
</p>
</div>
</div>
<div className="text-right min-w-[80px]">
<div className="text-2xl font-bold">{category.percentage}%</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
<div className="space-y-2">
<Slider
value={[category.weight * 100]}
onValueChange={([value]) => handleUpdateWeight(category.id, value)}
max={60}
min={5}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>5%</span>
<span>60%</span>
</div>
</div>
</div>
))}
</div>
</Card>
{/* 权重配置说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-2"></p>
<ul className="space-y-1 text-xs">
<li> <strong> (35%)</strong>: </li>
<li> <strong> (30%)</strong>: </li>
<li> <strong> (20%)</strong>: </li>
<li> <strong> (15%)</strong>: </li>
<li> 100%</li>
<li> </li>
</ul>
</div>
</div>
</Card>
{/* 预设配置 */}
<Card className="p-4">
<h3 className="font-medium mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Button
variant="outline"
onClick={() => dispatch({
type: 'SET_WEIGHT_CONFIG',
payload: { soil: 0.4, climate: 0.3, topography: 0.2, infrastructure: 0.1 }
})}
>
<div className="text-left">
<div className="font-medium text-sm"></div>
<div className="text-xs text-muted-foreground">40% · 30%</div>
</div>
</Button>
<Button
variant="outline"
onClick={() => dispatch({
type: 'SET_WEIGHT_CONFIG',
payload: { soil: 0.3, climate: 0.4, topography: 0.2, infrastructure: 0.1 }
})}
>
<div className="text-left">
<div className="font-medium text-sm"></div>
<div className="text-xs text-muted-foreground">40% · 30%</div>
</div>
</Button>
<Button
variant="outline"
onClick={() => dispatch({
type: 'SET_WEIGHT_CONFIG',
payload: { soil: 0.3, climate: 0.3, topography: 0.3, infrastructure: 0.1 }
})}
>
<div className="text-left">
<div className="font-medium text-sm"></div>
<div className="text-xs text-muted-foreground">30%</div>
</div>
</Button>
</div>
</Card>
</div>
);
if (isDialog) {
return (
<Dialog open={state.showWeightDialog} onOpenChange={() => dispatch({ type: 'TOGGLE_WEIGHT_DIALOG' })}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sliders className="w-5 h-5" />
</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-medium"></h3>
<div className="flex items-center gap-2">
<Badge variant={Math.abs(getTotalWeight() - 1) < 0.01 ? 'default' : 'destructive'}>
: {Math.round(getTotalWeight() * 100)}%
</Badge>
</div>
</div>
{content}
</div>
);
}

View File

@@ -0,0 +1,614 @@
'use client';
import { SpatialAnalysisState, SpatialAnalysisAction, LandBlock, AnalysisResult, BatchAnalysisTask, EvaluationFactor } from './spatialAnalysisReducer';
// 空间分析服务接口
interface SpatialAnalysisService {
analyzeBlock(block: LandBlock, factors: EvaluationFactor[], weights: any): Promise<{
success: boolean;
suitabilityIndex: number;
result?: AnalysisResult;
error?: string;
}>;
}
// 模拟空间分析服务
class MockSpatialAnalysisService implements SpatialAnalysisService {
async analyzeBlock(block: LandBlock, factors: EvaluationFactor[], weights: any): Promise<{
success: boolean;
suitabilityIndex: number;
result?: AnalysisResult;
error?: string;
}> {
// 模拟API调用延迟
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 1200));
try {
// 模拟分析失败的情况 (10% 概率)
if (Math.random() < 0.1) {
throw new Error('空间分析服务暂时不可用');
}
// 读取各项因子数据
const factorData = await this.readFactorData(block);
// 进行加权计算
const suitabilityIndex = this.calculateWeightedScore(factorData, factors, weights);
// 生成详细分析结果
const result = this.generateAnalysisResult(block, factorData, suitabilityIndex, weights);
return {
success: true,
suitabilityIndex,
result
};
} catch (error) {
return {
success: false,
suitabilityIndex: 0,
error: error instanceof Error ? error.message : '分析失败'
};
}
}
private async readFactorData(block: LandBlock): Promise<any> {
// 模拟从各种数据源读取因子数据
await new Promise(resolve => setTimeout(resolve, 200));
return {
// 土壤因子数据
soilFactors: {
ph: block.pH || (6.0 + Math.random() * 2),
organicMatter: block.organicMatter || (1.5 + Math.random() * 2),
nitrogen: block.nitrogen || (60 + Math.random() * 80),
phosphorus: block.phosphorus || (30 + Math.random() * 40),
potassium: block.potassium || (80 + Math.random() * 80),
soilType: block.soilType,
texture: this.getSoilTexture(block.soilType)
},
// 气候因子数据 (模拟从气象API获取)
climateFactors: {
temperature: 15 + Math.random() * 20, // 年均温度
rainfall: 400 + Math.random() * 800, // 年降雨量
sunlight: 1600 + Math.random() * 800, // 年日照时数
humidity: 50 + Math.random() * 30,
frostFreeDays: 180 + Math.random() * 60
},
// 地形因子数据 (模拟从地形API获取)
topographyFactors: {
elevation: block.elevation || (20 + Math.random() * 100),
slope: block.slope || (Math.random() * 10),
aspect: Math.floor(Math.random() * 360), // 坡向
relief: Math.random() * 50, // 地形起伏度
drainageIndex: 0.3 + Math.random() * 0.7 // 排水指数
},
// 基础设施数据
infrastructureFactors: {
irrigationType: block.irrigation,
irrigationScore: this.getIrrigationScore(block.irrigation),
accessibility: 0.4 + Math.random() * 0.6, // 交通便利性
distanceToRoad: Math.random() * 5, // 到道路距离(km)
distanceToMarket: Math.random() * 20, // 到市场距离(km)
powerSupply: Math.random() > 0.3, // 电力供应
waterSupply: Math.random() > 0.2 // 水源供应
}
};
}
private getSoilTexture(soilType: string): string {
const textureMap: { [key: string]: string } = {
'壤土': 'loam',
'砂壤土': 'sandy_loam',
'黏土': 'clay',
'砂土': 'sandy',
'砾质土': 'gravelly'
};
return textureMap[soilType] || 'loam';
}
private getIrrigationScore(irrigationType: string): number {
const scoreMap: { [key: string]: number } = {
'滴灌': 0.95,
'喷灌': 0.85,
'漫灌': 0.70,
'无灌溉': 0.30
};
return scoreMap[irrigationType] || 0.70;
}
private calculateWeightedScore(factorData: any, factors: EvaluationFactor[], weights: any): number {
let totalScore = 0;
let totalWeight = 0;
// 土壤因子得分计算
const soilWeight = weights.soil || 0.35;
const soilScore = this.calculateSoilScore(factorData.soilFactors, factors);
totalScore += soilScore * soilWeight;
totalWeight += soilWeight;
// 气候因子得分计算
const climateWeight = weights.climate || 0.30;
const climateScore = this.calculateClimateScore(factorData.climateFactors, factors);
totalScore += climateScore * climateWeight;
totalWeight += climateWeight;
// 地形因子得分计算
const topographyWeight = weights.topography || 0.20;
const topographyScore = this.calculateTopographyScore(factorData.topographyFactors, factors);
totalScore += topographyScore * topographyWeight;
totalWeight += topographyWeight;
// 基础设施因子得分计算
const infrastructureWeight = weights.infrastructure || 0.15;
const infrastructureScore = this.calculateInfrastructureScore(factorData.infrastructureFactors, factors);
totalScore += infrastructureScore * infrastructureWeight;
totalWeight += infrastructureWeight;
return Math.round(totalWeight > 0 ? (totalScore / totalWeight) * 100 : 0);
}
private calculateSoilScore(soilFactors: any, factors: EvaluationFactor[]): number {
let score = 0;
let count = 0;
// pH值评分
if (soilFactors.ph >= 6.0 && soilFactors.ph <= 7.5) {
score += 90;
} else if (soilFactors.ph >= 5.5 && soilFactors.ph <= 8.0) {
score += 70;
} else {
score += 40;
}
count++;
// 有机质评分
if (soilFactors.organicMatter >= 2.5) {
score += 90;
} else if (soilFactors.organicMatter >= 1.5) {
score += 70;
} else {
score += 45;
}
count++;
// 养分评分 (NPK)
const npkScore = Math.min(100, (soilFactors.nitrogen + soilFactors.phosphorus + soilFactors.potassium) / 3);
score += npkScore;
count++;
return count > 0 ? score / count : 50;
}
private calculateClimateScore(climateFactors: any, factors: EvaluationFactor[]): number {
let score = 0;
let count = 0;
// 温度适宜性
if (climateFactors.temperature >= 15 && climateFactors.temperature <= 25) {
score += 90;
} else if (climateFactors.temperature >= 10 && climateFactors.temperature <= 30) {
score += 70;
} else {
score += 50;
}
count++;
// 降雨适宜性
if (climateFactors.rainfall >= 500 && climateFactors.rainfall <= 1000) {
score += 90;
} else if (climateFactors.rainfall >= 300 && climateFactors.rainfall <= 1500) {
score += 70;
} else {
score += 45;
}
count++;
// 日照充足性
if (climateFactors.sunlight >= 2000) {
score += 90;
} else if (climateFactors.sunlight >= 1600) {
score += 75;
} else {
score += 60;
}
count++;
return count > 0 ? score / count : 50;
}
private calculateTopographyScore(topographyFactors: any, factors: EvaluationFactor[]): number {
let score = 0;
let count = 0;
// 坡度评分
if (topographyFactors.slope <= 3) {
score += 95;
} else if (topographyFactors.slope <= 6) {
score += 80;
} else if (topographyFactors.slope <= 15) {
score += 60;
} else {
score += 30;
}
count++;
// 海拔适宜性
if (topographyFactors.elevation <= 100) {
score += 90;
} else if (topographyFactors.elevation <= 300) {
score += 80;
} else if (topographyFactors.elevation <= 600) {
score += 65;
} else {
score += 45;
}
count++;
// 排水条件
score += topographyFactors.drainageIndex * 100;
count++;
return count > 0 ? score / count : 50;
}
private calculateInfrastructureScore(infrastructureFactors: any, factors: EvaluationFactor[]): number {
let score = 0;
let count = 0;
// 灌溉条件
score += infrastructureFactors.irrigationScore * 100;
count++;
// 交通便利性
score += infrastructureFactors.accessibility * 100;
count++;
// 基础设施完善度
const infrastructureScore =
(infrastructureFactors.powerSupply ? 50 : 0) +
(infrastructureFactors.waterSupply ? 50 : 0);
score += infrastructureScore;
count++;
return count > 0 ? score / count : 50;
}
private generateAnalysisResult(block: LandBlock, factorData: any, suitabilityIndex: number, weights: any): AnalysisResult {
const soilScore = this.calculateSoilScore(factorData.soilFactors, []);
const climateScore = this.calculateClimateScore(factorData.climateFactors, []);
const topographyScore = this.calculateTopographyScore(factorData.topographyFactors, []);
const infrastructureScore = this.calculateInfrastructureScore(factorData.infrastructureFactors, []);
// 生成作物推荐
const recommendedCrops = this.generateCropRecommendations(block, factorData, suitabilityIndex);
// 生成风险因素
const riskFactors = this.generateRiskFactors(block, factorData, suitabilityIndex);
// 生成改进建议
const improvementSuggestions = this.generateImprovementSuggestions(block, factorData, soilScore, climateScore, topographyScore, infrastructureScore);
return {
blockId: block.id,
blockName: block.name,
overallScore: suitabilityIndex,
soilScore,
climateScore,
topographyScore,
infrastructureScore,
recommendedCrops,
riskFactors,
improvementSuggestions,
estimatedYield: Math.round(block.area * (suitabilityIndex / 100) * (6000 + Math.random() * 4000)),
economicReturn: Math.round(block.area * (suitabilityIndex / 100) * (8000 + Math.random() * 6000))
};
}
private generateCropRecommendations(block: LandBlock, factorData: any, suitabilityIndex: number): any[] {
// 简化的作物推荐逻辑
const crops = [
{ name: '小麦', suitabilityScore: Math.min(95, suitabilityIndex + Math.random() * 10 - 5) },
{ name: '玉米', suitabilityScore: Math.min(95, suitabilityIndex + Math.random() * 15 - 5) },
{ name: '水稻', suitabilityScore: factorData.climateFactors.rainfall > 800 ? Math.min(95, suitabilityIndex + Math.random() * 10) : Math.max(40, suitabilityIndex - 20) },
{ name: '大豆', suitabilityScore: Math.min(95, suitabilityIndex + Math.random() * 8 - 4) },
{ name: '棉花', suitabilityScore: Math.min(95, suitabilityIndex + Math.random() * 12 - 6) }
];
return crops
.map(crop => ({
crop: {
id: crop.name.toLowerCase(),
name: crop.name,
category: '粮食作物',
growthCycle: 100 + Math.floor(Math.random() * 50),
economicValue: 2000 + Math.floor(Math.random() * 6000),
riskLevel: crop.suitabilityScore > 70 ? 'low' : crop.suitabilityScore > 50 ? 'medium' : 'high' as const
},
suitabilityScore: Math.round(crop.suitabilityScore),
matchPercentage: Math.round(crop.suitabilityScore),
advantages: crop.suitabilityScore > 70 ? ['土壤条件适宜', '气候匹配度高'] : ['需要改善条件'],
disadvantages: crop.suitabilityScore < 60 ? ['土壤需要改良', '气候条件一般'] : [],
managementNotes: ['注意合理施肥', '加强田间管理']
}))
.filter(crop => crop.suitabilityScore > 40)
.sort((a, b) => b.suitabilityScore - a.suitabilityScore)
.slice(0, 3);
}
private generateRiskFactors(block: LandBlock, factorData: any, suitabilityIndex: number): string[] {
const risks = [];
if (suitabilityIndex < 40) {
risks.push('综合条件较差,种植风险较高');
}
if (factorData.soilFactors.ph < 6.0 || factorData.soilFactors.ph > 7.5) {
risks.push('土壤pH值偏离理想范围');
}
if (factorData.soilFactors.organicMatter < 1.5) {
risks.push('土壤有机质含量偏低');
}
if (factorData.topographyFactors.slope > 8) {
risks.push('坡度较大,存在水土流失风险');
}
if (factorData.infrastructureFactors.irrigationScore < 0.5) {
risks.push('灌溉条件不足,依赖自然降水');
}
if (factorData.climateFactors.rainfall < 400) {
risks.push('降雨量偏少,干旱风险较高');
}
return risks.length > 0 ? risks : ['无明显风险因素'];
}
private generateImprovementSuggestions(block: LandBlock, factorData: any, soilScore: number, climateScore: number, topographyScore: number, infrastructureScore: number): string[] {
const suggestions = [];
if (soilScore < 70) {
suggestions.push('增施有机肥,改善土壤结构');
}
if (soilScore < 60) {
suggestions.push('进行土壤检测,针对性补充养分');
}
if (climateScore < 70) {
suggestions.push('选择适应性更强的作物品种');
}
if (topographyScore < 70) {
suggestions.push('修建梯田或等高线种植');
}
if (infrastructureScore < 70) {
suggestions.push('完善灌溉和排水系统');
}
if (factorData.infrastructureFactors.accessibility < 0.6) {
suggestions.push('改善田间道路条件');
}
return suggestions.length > 0 ? suggestions : ['当前条件良好,继续保持'];
}
}
// 批量分析管理器
export class BatchAnalysisManager {
private service: SpatialAnalysisService;
private dispatch: React.Dispatch<SpatialAnalysisAction>;
private isRunning: boolean = false;
private currentTask: BatchAnalysisTask | null = null;
constructor(dispatch: React.Dispatch<SpatialAnalysisAction>) {
this.service = new MockSpatialAnalysisService();
this.dispatch = dispatch;
}
async startBatchAnalysis(
taskName: string,
factors: EvaluationFactor[],
weightConfig: any
): Promise<void> {
if (this.isRunning) {
throw new Error('已有分析任务正在运行');
}
// 固定分析68个地块
const totalFields = 68;
const blockIds = Array.from({ length: totalFields }, (_, i) => `field-${i + 1}`);
// 创建批量分析任务
this.dispatch({
type: 'START_BATCH_ANALYSIS',
payload: { name: taskName, blockIds }
});
this.isRunning = true;
try {
// 获取创建的任务
const task = await this.waitForTaskCreation();
if (!task) return;
this.currentTask = task;
// 更新任务状态为运行中
this.dispatch({
type: 'UPDATE_BATCH_TASK',
payload: {
taskId: task.id,
updates: {
status: 'running',
startedAt: new Date().toLocaleString('zh-CN'),
totalBlocks: totalFields
}
}
});
// 循环处理所有地块
const results: AnalysisResult[] = [];
let highSuitability = 0;
let mediumSuitability = 0;
let lowSuitability = 0;
for (let i = 0; i < totalFields; i++) {
if (!this.isRunning) break; // 检查是否被取消
const fieldId = `field-${i + 1}`;
const fieldName = `地块${String.fromCharCode(65 + (i % 26))}${Math.floor(i / 26) + 1}`;
// 创建临时地块对象
const tempBlock: LandBlock = {
id: fieldId,
name: fieldName,
area: 10 + Math.random() * 20, // 随机面积10-30亩
location: { lat: 39.9042 + (Math.random() - 0.5) * 0.1, lng: 116.4074 + (Math.random() - 0.5) * 0.1 },
soilType: ['壤土', '砂壤土', '黏土'][Math.floor(Math.random() * 3)],
elevation: 20 + Math.random() * 100,
slope: Math.random() * 10,
irrigation: ['滴灌', '喷灌', '漫灌'][Math.floor(Math.random() * 3)],
pH: 6.0 + Math.random() * 2,
organicMatter: 1.5 + Math.random() * 2,
nitrogen: 60 + Math.random() * 80,
phosphorus: 30 + Math.random() * 40,
potassium: 80 + Math.random() * 80
};
// 更新当前处理的地块
this.dispatch({
type: 'UPDATE_BATCH_TASK',
payload: {
taskId: task.id,
updates: {
currentProcessingBlock: fieldName,
processedBlocks: i,
progress: Math.round((i / totalFields) * 100)
}
}
});
try {
// 调用空间分析服务
const analysisResult = await this.service.analyzeBlock(tempBlock, factors, weightConfig);
if (analysisResult.success && analysisResult.result) {
results.push(analysisResult.result);
// 统计适宜性等级
const score = analysisResult.suitabilityIndex;
if (score >= 80) {
highSuitability++;
} else if (score >= 60) {
mediumSuitability++;
} else {
lowSuitability++;
}
}
// 更新任务统计
this.dispatch({
type: 'UPDATE_BATCH_TASK',
payload: {
taskId: task.id,
updates: {
successCount: results.length,
failedCount: i + 1 - results.length,
progress: Math.round(((i + 1) / totalFields) * 100)
}
}
});
} catch (error) {
console.error(`分析地块 ${fieldName} 失败:`, error);
}
}
// 完成批量分析
this.dispatch({
type: 'COMPLETE_BATCH_ANALYSIS',
payload: { taskId: task.id, results }
});
} catch (error) {
console.error('批量分析失败:', error);
if (this.currentTask) {
this.dispatch({
type: 'UPDATE_BATCH_TASK',
payload: {
taskId: this.currentTask.id,
updates: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : '未知错误'
}
}
});
}
} finally {
this.isRunning = false;
this.currentTask = null;
}
}
pauseAnalysis(): void {
if (this.currentTask) {
this.dispatch({
type: 'PAUSE_BATCH_ANALYSIS',
payload: { taskId: this.currentTask.id }
});
}
this.isRunning = false;
}
resumeAnalysis(): void {
if (this.currentTask) {
this.dispatch({
type: 'RESUME_BATCH_ANALYSIS',
payload: { taskId: this.currentTask.id }
});
this.isRunning = true;
}
}
cancelAnalysis(): void {
if (this.currentTask) {
this.dispatch({
type: 'CANCEL_BATCH_ANALYSIS',
payload: { taskId: this.currentTask.id }
});
}
this.isRunning = false;
this.currentTask = null;
}
private async waitForTaskCreation(): Promise<BatchAnalysisTask | null> {
// 等待任务创建完成 - 直接返回一个虚拟任务
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: Date.now().toString(),
name: '批量分析任务',
status: 'running',
createdAt: new Date().toLocaleString('zh-CN'),
totalBlocks: 68,
processedBlocks: 0,
successCount: 0,
failedCount: 0,
selectedBlockIds: [],
weightConfig: {},
factorIds: [],
progress: 0
});
}, 100);
});
}
}
export default BatchAnalysisManager;

View File

@@ -0,0 +1,621 @@
'use client';
// 空间分析数据类型定义
export interface LandBlock {
id: string;
name: string;
area: number;
location: {
lat: number;
lng: number;
};
soilType: string;
currentCrop?: string;
elevation?: number;
slope?: number;
irrigation?: string;
pH?: number;
organicMatter?: number;
nitrogen?: number;
phosphorus?: number;
potassium?: number;
suitabilityIndex?: number; // 适宜性指数
lastAnalyzed?: string; // 最后分析时间
analysisStatus?: 'pending' | 'analyzing' | 'completed' | 'failed'; // 分析状态
}
export interface CropInfo {
id: string;
name: string;
category: string;
growthCycle: number;
soilRequirements: {
pH: { min: number; max: number };
organicMatter: { min: number };
nitrogen: { min: number };
phosphorus: { min: number };
potassium: { min: number };
};
climateRequirements: {
temperature: { min: number; max: number };
rainfall: { min: number; max: number };
sunlight: { min: number };
};
economicValue: number;
riskLevel: 'low' | 'medium' | 'high';
suitableRegions: string[];
}
export interface EvaluationFactor {
id: string;
name: string;
weight: number;
enabled: boolean;
category: 'soil' | 'climate' | 'topography' | 'infrastructure';
description: string;
}
export interface AnalysisResult {
blockId: string;
blockName: string;
overallScore: number;
soilScore: number;
climateScore: number;
topographyScore: number;
infrastructureScore: number;
recommendedCrops: CropRecommendation[];
riskFactors: string[];
improvementSuggestions: string[];
estimatedYield?: number;
economicReturn?: number;
}
export interface CropRecommendation {
crop: CropInfo;
suitabilityScore: number;
matchPercentage: number;
advantages: string[];
disadvantages: string[];
managementNotes: string[];
}
export interface WeightConfig {
soil: number;
climate: number;
topography: number;
infrastructure: number;
}
export interface AnalysisReport {
id: string;
title: string;
createdAt: string;
landBlocks: LandBlock[];
results: AnalysisResult[];
factors: EvaluationFactor[];
weightConfig: WeightConfig;
summary: {
totalBlocks: number;
averageScore: number;
topRecommendedCrops: Array<{ crop: string; frequency: number; avgScore: number }>;
riskDistribution: { low: number; medium: number; high: number };
};
}
// 批量分析任务状态
export interface BatchAnalysisTask {
id: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'paused';
createdAt: string;
startedAt?: string;
completedAt?: string;
totalBlocks: number;
processedBlocks: number;
successCount: number;
failedCount: number;
selectedBlockIds: string[];
weightConfig: WeightConfig;
factorIds: string[];
progress: number;
currentProcessingBlock?: string;
errorMessage?: string;
}
// 状态管理
export interface SpatialAnalysisState {
// 地块数据
landBlocks: LandBlock[];
selectedBlocks: string[];
// 评价因子
factors: EvaluationFactor[];
weightConfig: WeightConfig;
// 作物数据库
cropDatabase: CropInfo[];
selectedCrops: string[];
// 分析结果
analysisResults: AnalysisResult[];
currentReport: AnalysisReport | null;
// 批量分析任务
batchTasks: BatchAnalysisTask[];
currentTask: BatchAnalysisTask | null;
isBatchAnalyzing: boolean;
// UI状态
activeTab: string;
isAnalyzing: boolean;
showWeightDialog: boolean;
showFactorDialog: boolean;
showCompareDialog: boolean;
// 筛选条件
filters: {
scoreRange: [number, number];
soilTypes: string[];
riskLevels: string[];
cropCategories: string[];
};
// 图表数据
chartData: {
scoreDistribution: Array<{ range: string; count: number }>;
factorPerformance: Array<{ factor: string; score: number; weight: number }>;
cropComparison: Array<{ crop: string; suitability: number; economic: number; risk: string }>;
};
}
// 动作类型
export type SpatialAnalysisAction =
| { type: 'SET_LAND_BLOCKS'; payload: LandBlock[] }
| { type: 'ADD_LAND_BLOCK'; payload: LandBlock }
| { type: 'UPDATE_LAND_BLOCK'; payload: { id: string; updates: Partial<LandBlock> } }
| { type: 'DELETE_LAND_BLOCK'; payload: string }
| { type: 'SET_SELECTED_BLOCKS'; payload: string[] }
| { type: 'TOGGLE_BLOCK_SELECTION'; payload: string }
| { type: 'SET_FACTORS'; payload: EvaluationFactor[] }
| { type: 'UPDATE_FACTOR_WEIGHT'; payload: { id: string; weight: number } }
| { type: 'TOGGLE_FACTOR'; payload: string }
| { type: 'SET_WEIGHT_CONFIG'; payload: WeightConfig }
| { type: 'SET_CROP_DATABASE'; payload: CropInfo[] }
| { type: 'SET_SELECTED_CROPS'; payload: string[] }
| { type: 'SET_ANALYSIS_RESULTS'; payload: AnalysisResult[] }
| { type: 'ADD_ANALYSIS_RESULT'; payload: AnalysisResult }
| { type: 'SET_CURRENT_REPORT'; payload: AnalysisReport | null }
| { type: 'SET_ACTIVE_TAB'; payload: string }
| { type: 'SET_ANALYZING'; payload: boolean }
| { type: 'TOGGLE_WEIGHT_DIALOG' }
| { type: 'TOGGLE_FACTOR_DIALOG' }
| { type: 'TOGGLE_COMPARE_DIALOG' }
| { type: 'SET_FILTERS'; payload: Partial<SpatialAnalysisState['filters']> }
| { type: 'RESET_FILTERS' }
| { type: 'SET_CHART_DATA'; payload: Partial<SpatialAnalysisState['chartData']> }
| { type: 'RUN_ANALYSIS'; payload: { blockIds?: string[] } }
| { type: 'GENERATE_REPORT'; payload: { title: string } }
// 批量分析相关动作
| { type: 'START_BATCH_ANALYSIS'; payload: { name: string; blockIds: string[] } }
| { type: 'UPDATE_BATCH_TASK'; payload: { taskId: string; updates: Partial<BatchAnalysisTask> } }
| { type: 'SET_CURRENT_TASK'; payload: BatchAnalysisTask | null }
| { type: 'COMPLETE_BATCH_ANALYSIS'; payload: { taskId: string; results: AnalysisResult[] } }
| { type: 'PAUSE_BATCH_ANALYSIS'; payload: { taskId: string } }
| { type: 'RESUME_BATCH_ANALYSIS'; payload: { taskId: string } }
| { type: 'CANCEL_BATCH_ANALYSIS'; payload: { taskId: string } }
| { type: 'UPDATE_BLOCK_ANALYSIS_STATUS'; payload: { blockId: string; status: LandBlock['analysisStatus']; suitabilityIndex?: number } }
| { type: 'LOAD_FROM_STORAGE' }
| { type: 'SAVE_TO_STORAGE' }
| { type: 'RESET_STATE' };
// 默认评价因子
const defaultFactors: EvaluationFactor[] = [
{
id: 'soil_ph',
name: '土壤pH值',
weight: 0.15,
enabled: true,
category: 'soil',
description: '土壤酸碱度对作物生长的影响'
},
{
id: 'soil_organic_matter',
name: '有机质含量',
weight: 0.12,
enabled: true,
category: 'soil',
description: '土壤肥力的重要指标'
},
{
id: 'soil_nutrients',
name: '土壤养分',
weight: 0.18,
enabled: true,
category: 'soil',
description: '氮磷钾等营养元素含量'
},
{
id: 'soil_texture',
name: '土壤质地',
weight: 0.10,
enabled: true,
category: 'soil',
description: '土壤物理结构和透气性'
},
{
id: 'temperature',
name: '温度条件',
weight: 0.15,
enabled: true,
category: 'climate',
description: '积温和温度适宜性'
},
{
id: 'rainfall',
name: '降水条件',
weight: 0.12,
enabled: true,
category: 'climate',
description: '降雨量及其分布'
},
{
id: 'sunlight',
name: '光照条件',
weight: 0.10,
enabled: true,
category: 'climate',
description: '日照时数和光照强度'
},
{
id: 'elevation',
name: '海拔高度',
weight: 0.08,
enabled: true,
category: 'topography',
description: '海拔对气候和作物的影响'
},
{
id: 'slope',
name: '坡度坡向',
weight: 0.06,
enabled: true,
category: 'topography',
description: '地形对水土保持的影响'
},
{
id: 'irrigation',
name: '灌溉条件',
weight: 0.12,
enabled: true,
category: 'infrastructure',
description: '水利设施和灌溉保障'
},
{
id: 'accessibility',
name: '交通便利性',
weight: 0.05,
enabled: true,
category: 'infrastructure',
description: '道路和运输条件'
}
];
// 默认权重配置
const defaultWeightConfig: WeightConfig = {
soil: 0.35,
climate: 0.30,
topography: 0.20,
infrastructure: 0.15
};
// 初始状态
export const initialSpatialAnalysisState: SpatialAnalysisState = {
landBlocks: [],
selectedBlocks: [],
factors: defaultFactors,
weightConfig: defaultWeightConfig,
cropDatabase: [],
selectedCrops: [],
analysisResults: [],
currentReport: null,
batchTasks: [],
currentTask: null,
isBatchAnalyzing: false,
activeTab: 'overview',
isAnalyzing: false,
showWeightDialog: false,
showFactorDialog: false,
showCompareDialog: false,
filters: {
scoreRange: [0, 100],
soilTypes: [],
riskLevels: [],
cropCategories: []
},
chartData: {
scoreDistribution: [],
factorPerformance: [],
cropComparison: []
}
};
// Reducer
export function spatialAnalysisReducer(
state: SpatialAnalysisState,
action: SpatialAnalysisAction
): SpatialAnalysisState {
switch (action.type) {
case 'SET_LAND_BLOCKS':
return { ...state, landBlocks: action.payload };
case 'ADD_LAND_BLOCK':
return { ...state, landBlocks: [...state.landBlocks, action.payload] };
case 'UPDATE_LAND_BLOCK':
return {
...state,
landBlocks: state.landBlocks.map(block =>
block.id === action.payload.id
? { ...block, ...action.payload.updates }
: block
)
};
case 'DELETE_LAND_BLOCK':
return {
...state,
landBlocks: state.landBlocks.filter(block => block.id !== action.payload),
selectedBlocks: state.selectedBlocks.filter(id => id !== action.payload)
};
case 'SET_SELECTED_BLOCKS':
return { ...state, selectedBlocks: action.payload };
case 'TOGGLE_BLOCK_SELECTION':
const isSelected = state.selectedBlocks.includes(action.payload);
return {
...state,
selectedBlocks: isSelected
? state.selectedBlocks.filter(id => id !== action.payload)
: [...state.selectedBlocks, action.payload]
};
case 'SET_FACTORS':
return { ...state, factors: action.payload };
case 'UPDATE_FACTOR_WEIGHT':
return {
...state,
factors: state.factors.map(factor =>
factor.id === action.payload.id
? { ...factor, weight: action.payload.weight }
: factor
)
};
case 'TOGGLE_FACTOR':
return {
...state,
factors: state.factors.map(factor =>
factor.id === action.payload
? { ...factor, enabled: !factor.enabled }
: factor
)
};
case 'SET_WEIGHT_CONFIG':
return { ...state, weightConfig: action.payload };
case 'SET_CROP_DATABASE':
return { ...state, cropDatabase: action.payload };
case 'SET_SELECTED_CROPS':
return { ...state, selectedCrops: action.payload };
case 'SET_ANALYSIS_RESULTS':
return { ...state, analysisResults: action.payload };
case 'ADD_ANALYSIS_RESULT':
return { ...state, analysisResults: [...state.analysisResults, action.payload] };
case 'SET_CURRENT_REPORT':
return { ...state, currentReport: action.payload };
case 'SET_ACTIVE_TAB':
return { ...state, activeTab: action.payload };
case 'SET_ANALYZING':
return { ...state, isAnalyzing: action.payload };
case 'TOGGLE_WEIGHT_DIALOG':
return { ...state, showWeightDialog: !state.showWeightDialog };
case 'TOGGLE_FACTOR_DIALOG':
return { ...state, showFactorDialog: !state.showFactorDialog };
case 'TOGGLE_COMPARE_DIALOG':
return { ...state, showCompareDialog: !state.showCompareDialog };
case 'SET_FILTERS':
return {
...state,
filters: { ...state.filters, ...action.payload }
};
case 'RESET_FILTERS':
return {
...state,
filters: {
scoreRange: [0, 100],
soilTypes: [],
riskLevels: [],
cropCategories: []
}
};
case 'SET_CHART_DATA':
return {
...state,
chartData: { ...state.chartData, ...action.payload }
};
case 'RUN_ANALYSIS':
// 这里会触发分析逻辑,实际实现在组件中处理
return { ...state, isAnalyzing: true };
case 'GENERATE_REPORT':
// 报告生成逻辑在组件中处理
return state;
// 批量分析相关处理
case 'START_BATCH_ANALYSIS':
const newTask: BatchAnalysisTask = {
id: Date.now().toString(),
name: action.payload.name,
status: 'pending',
createdAt: new Date().toLocaleString('zh-CN'),
totalBlocks: action.payload.blockIds.length,
processedBlocks: 0,
successCount: 0,
failedCount: 0,
selectedBlockIds: action.payload.blockIds,
weightConfig: state.weightConfig,
factorIds: state.factors.filter(f => f.enabled).map(f => f.id),
progress: 0
};
return {
...state,
batchTasks: [...state.batchTasks, newTask],
currentTask: newTask,
isBatchAnalyzing: true
};
case 'UPDATE_BATCH_TASK':
return {
...state,
batchTasks: state.batchTasks.map(task =>
task.id === action.payload.taskId
? { ...task, ...action.payload.updates }
: task
),
currentTask: state.currentTask?.id === action.payload.taskId
? { ...state.currentTask, ...action.payload.updates }
: state.currentTask
};
case 'SET_CURRENT_TASK':
return {
...state,
currentTask: action.payload
};
case 'COMPLETE_BATCH_ANALYSIS':
return {
...state,
batchTasks: state.batchTasks.map(task =>
task.id === action.payload.taskId
? {
...task,
status: 'completed',
completedAt: new Date().toLocaleString('zh-CN'),
progress: 100
}
: task
),
analysisResults: action.payload.results,
isBatchAnalyzing: false
};
case 'PAUSE_BATCH_ANALYSIS':
return {
...state,
batchTasks: state.batchTasks.map(task =>
task.id === action.payload.taskId
? { ...task, status: 'paused' }
: task
),
isBatchAnalyzing: false
};
case 'RESUME_BATCH_ANALYSIS':
return {
...state,
batchTasks: state.batchTasks.map(task =>
task.id === action.payload.taskId
? { ...task, status: 'running' }
: task
),
isBatchAnalyzing: true
};
case 'CANCEL_BATCH_ANALYSIS':
return {
...state,
batchTasks: state.batchTasks.map(task =>
task.id === action.payload.taskId
? { ...task, status: 'failed', errorMessage: '用户取消分析' }
: task
),
currentTask: null,
isBatchAnalyzing: false
};
case 'UPDATE_BLOCK_ANALYSIS_STATUS':
return {
...state,
landBlocks: state.landBlocks.map(block =>
block.id === action.payload.blockId
? {
...block,
analysisStatus: action.payload.status,
...(action.payload.suitabilityIndex && { suitabilityIndex: action.payload.suitabilityIndex }),
lastAnalyzed: action.payload.status === 'completed' ? new Date().toLocaleString('zh-CN') : block.lastAnalyzed
}
: block
)
};
case 'LOAD_FROM_STORAGE':
try {
const savedData = localStorage.getItem('spatial-analysis-data');
if (savedData) {
const parsed = JSON.parse(savedData);
return { ...state, ...parsed };
}
} catch (error) {
console.error('Failed to load spatial analysis data:', error);
}
return state;
case 'SAVE_TO_STORAGE':
try {
const dataToSave = {
landBlocks: state.landBlocks,
selectedBlocks: state.selectedBlocks,
factors: state.factors,
weightConfig: state.weightConfig,
analysisResults: state.analysisResults,
filters: state.filters
};
localStorage.setItem('spatial-analysis-data', JSON.stringify(dataToSave));
} catch (error) {
console.error('Failed to save spatial analysis data:', error);
}
return state;
case 'RESET_STATE':
return initialSpatialAnalysisState;
default:
return state;
}
}

View File

@@ -0,0 +1,26 @@
'use client';
import { useReducer, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { spatialAnalysisReducer, initialSpatialAnalysisState, SpatialAnalysisState } from './components/spatialAnalysisReducer';
import SpatialAnalysisContent from './components/SpatialAnalysisContent';
export default function BatchEvaluationPage() {
const [state, dispatch] = useReducer(spatialAnalysisReducer, initialSpatialAnalysisState);
useEffect(() => {
// 加载存储的数据
dispatch({ type: 'LOAD_FROM_STORAGE' });
}, []);
useEffect(() => {
// 保存数据到localStorage
dispatch({ type: 'SAVE_TO_STORAGE' });
}, [state.factors, state.weightConfig, state.analysisResults, state.batchTasks]);
return (
<div className="space-y-6">
<SpatialAnalysisContent state={state} dispatch={dispatch} />
</div>
);
}

View File

@@ -1,18 +0,0 @@
'use client';
import { Card } from '@/components/ui/card';
export default function BatchPage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/suitability/batch
</p>
</div>
</Card>
</div>
);
}

View File

@@ -1,18 +0,0 @@
'use client';
import { Card } from '@/components/ui/card';
export default function ComprehensivePage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/suitability/comprehensive
</p>
</div>
</Card>
</div>
);
}

View File

@@ -1,18 +0,0 @@
'use client';
import { Card } from '@/components/ui/card';
export default function CropPage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/suitability/crop
</p>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,700 @@
/**
* 多因子综合评价组件
* 提供地块适宜性评价、权重配置、批量分析和作物推荐功能
*/
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import { Slider } from '@/components/ui/slider';
import { Textarea } from '@/components/ui/textarea';
import {
Leaf,
TrendingUp,
Award,
AlertTriangle,
CheckCircle2,
Play,
Settings,
Download,
Eye,
Calculator,
Database,
RefreshCw,
Zap,
Target,
Droplet,
Cloud,
Sun,
ThermometerSun,
BookOpen,
Beaker,
Info,
BarChart3,
Filter
} from 'lucide-react';
import { toast } from 'sonner';
import {
EvaluationFactor,
SuitabilityResult,
FactorWeight,
BatchProgress,
getGradeColor,
getScoreColor,
getSuitabilityLevelColor,
formatDate,
MOCK_FIELDS
} from './multiFactorTypes';
import {
MultiFactorService
} from './multiFactorService';
import {
matchCropsForField,
cropKnowledgeBase
} from './cropKnowledgeBase';
export function MultiFactorEvaluation() {
const [selectedField, setSelectedField] = useState('field-1');
const [showWeightConfig, setShowWeightConfig] = useState(false);
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgress>({
total: 0,
processed: 0,
highSuitability: 0,
mediumSuitability: 0,
lowSuitability: 0,
currentField: '',
});
const [isBatchRunning, setIsBatchRunning] = useState(false);
const [batchAnalysisResults, setBatchAnalysisResults] = useState<SuitabilityResult[]>([]);
// 评价因子权重配置
const [factorWeights, setFactorWeights] = useState<FactorWeight[]>([
{ id: 'ph', name: 'pH值', weight: 20, unit: '' },
{ id: 'organic', name: '有机质含量', weight: 25, unit: 'g/kg' },
{ id: 'depth', name: '土层厚度', weight: 20, unit: 'cm' },
{ id: 'nitrogen', name: '全氮', weight: 10, unit: 'g/kg' },
{ id: 'phosphorus', name: '全磷', weight: 10, unit: 'g/kg' },
{ id: 'potassium', name: '全钾', weight: 10, unit: 'g/kg' },
{ id: 'drainage', name: '排水性', weight: 5, unit: '' },
]);
// 模拟适宜性评价结果
const [evaluationResults, setEvaluationResults] = useState<SuitabilityResult[]>(
MultiFactorService.generateMockEvaluationData()
);
// 获取当前选中的地块结果
const currentResult =
evaluationResults.find(r => r.fieldId === selectedField) ||
batchAnalysisResults.find(r => r.fieldId === selectedField) ||
evaluationResults[0];
// 批量分析处理函数
const handleRunBatchAnalysis = async () => {
const validation = MultiFactorService.validateWeights(factorWeights);
if (!validation.isValid) {
toast.error(`权重总和必须为100%才能进行批量分析(当前:${validation.totalWeight}%`);
return;
}
setIsBatchRunning(true);
setBatchProgress({
total: 68,
processed: 0,
highSuitability: 0,
mediumSuitability: 0,
lowSuitability: 0,
currentField: '',
});
setBatchAnalysisResults([]);
toast.success('开始批量分析,正在读取地块数据...');
try {
const results = await MultiFactorService.runBatchAnalysis(
factorWeights,
(progress) => {
setBatchProgress(progress);
}
);
setBatchAnalysisResults(results);
setIsBatchRunning(false);
toast.success(`批量分析完成!已为${results.length}个地块生成适宜性评价结果`);
} catch (error) {
setIsBatchRunning(false);
toast.error('批量分析失败');
}
};
const handleUpdateWeight = (id: string, newWeight: number) => {
setFactorWeights(prev =>
MultiFactorService.updateWeight(prev, id, newWeight)
);
};
const handleResetWeights = () => {
setFactorWeights(MultiFactorService.resetWeights());
toast.success('权重已恢复默认值');
};
const handleApplyPreset = (preset: 'grain' | 'economic' | 'default') => {
setFactorWeights(MultiFactorService.getWeightPreset(preset));
const presetName = preset === 'grain' ? '粮食作物' : preset === 'economic' ? '经济作物' : '默认';
toast.success(`已应用${presetName}权重方案`);
};
const totalWeight = factorWeights.reduce((sum, f) => sum + f.weight, 0);
// 执行地块适宜性评价
const handleEvaluateField = () => {
const validation = MultiFactorService.validateWeights(factorWeights);
if (!validation.isValid) {
toast.error(`权重总和必须为100%才能进行评价(当前:${validation.totalWeight}%`);
return;
}
try {
const result = MultiFactorService.generateEvaluationResult(selectedField, factorWeights);
setEvaluationResults(prev =>
prev.map(r => r.fieldId === selectedField ? result : r)
);
toast.success('评价完成!已应用当前权重配置计算综合得分');
} catch (error) {
toast.error('评价失败');
}
};
const exportResults = () => {
const resultsToExport = batchAnalysisResults.length > 0 ? batchAnalysisResults : evaluationResults;
toast.success('正在导出评价结果...');
// 模拟导出功能
setTimeout(() => {
toast.success(`已导出${resultsToExport.length}个地块的评价结果`);
}, 2000);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowKnowledgeBase(true)}>
<BookOpen className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => setShowWeightConfig(true)}>
<Settings className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 多因子综合评价 */}
<div className="space-y-4">
<Card className="p-4 bg-card">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-2 block"></label>
<Select value={selectedField} onValueChange={setSelectedField}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{evaluationResults.map((result) => (
<SelectItem key={result.fieldId} value={result.fieldId}>
{result.fieldName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2 items-end">
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleEvaluateField}
>
<Play className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</Card>
{/* 评价结果总览 */}
<div className="grid grid-cols-4 gap-4">
<Card className="p-6 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900">
<div className="text-center">
<Award className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-3" />
<p className="text-xs text-muted-foreground mb-2"></p>
<p
className="text-4xl mb-2"
style={{ color: getGradeColor(currentResult.grade) }}
>
{currentResult.totalScore}
</p>
<Badge
className="text-white font-light"
style={{ backgroundColor: getGradeColor(currentResult.grade) }}
>
{currentResult.grade}
</Badge>
</div>
</Card>
<Card className="p-6 bg-card">
<div className="text-center">
<CheckCircle2 className="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-4xl text-blue-600 dark:text-blue-400 mb-2">
{currentResult.factors.filter(f => f.score >= 80).length}
</p>
<p className="text-xs text-muted-foreground">
/ {currentResult.factors.length}
</p>
</div>
</Card>
<Card className="p-6 bg-card">
<div className="text-center">
<AlertTriangle className="w-12 h-12 text-yellow-600 dark:text-yellow-400 mx-auto mb-3" />
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-4xl text-yellow-600 dark:text-yellow-400 mb-2">
{currentResult.factors.filter(f => f.score < 70).length}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</Card>
<Card className="p-6 bg-card">
<div className="text-center">
<TrendingUp className="w-12 h-12 text-purple-600 dark:text-purple-400 mx-auto mb-3" />
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-sm text-purple-600 dark:text-purple-400 mb-2">
{formatDate(currentResult.timestamp)}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</Card>
</div>
{/* 因子详细评分 */}
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="space-y-4">
{currentResult.factors.map((factor) => (
<div key={factor.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{factor.name}</span>
<Badge variant="outline" className="text-xs font-light">
: {factor.weight}%
</Badge>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
: {factor.value.toFixed(1)}{factor.unit}
</span>
<span className="text-sm text-muted-foreground">
: {factor.optimalRange[0]}-{factor.optimalRange[1]}{factor.unit}
</span>
<span
className="text-sm font-medium"
style={{ color: getScoreColor(factor.score) }}
>
{factor.score}
</span>
</div>
</div>
<div className="relative">
<Progress value={factor.score} className="h-2" />
<div className="absolute top-0 left-0 h-2 w-full flex items-center">
<div
className="absolute h-3 w-1 bg-blue-500"
style={{
left: `${((factor.optimalRange[0] - 0) / (factor.optimalRange[1] * 1.5)) * 100}%`
}}
/>
<div
className="absolute h-3 w-1 bg-blue-500"
style={{
left: `${((factor.optimalRange[1] - 0) / (factor.optimalRange[1] * 1.5)) * 100}%`
}}
/>
</div>
</div>
</div>
))}
</div>
</Card>
{/* 加权计算说明 */}
<Card className="p-6 bg-card">
<h3 className="mb-4">(AHP)</h3>
<div className="space-y-4">
<div className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<p className="text-sm text-blue-900 dark:text-blue-100 mb-2"></p>
<code className="text-xs text-blue-800 dark:text-blue-200 block mb-2">
= Σ( × )
</code>
<p className="text-xs text-blue-800 dark:text-blue-200">
= ({currentResult.factors.map((f, i) =>
`${f.score} × ${f.weight}%${i < currentResult.factors.length - 1 ? ' + ' : ''}`
).join('')})
</p>
<p className="text-xs text-blue-800 dark:text-blue-200 mt-2">
= {currentResult.totalScore}
</p>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-green-50 dark:bg-green-950 rounded-lg">
<h4 className="text-green-900 dark:text-green-100 mb-2"> (80)</h4>
<ul className="text-sm text-green-800 dark:text-green-200 space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="p-4 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<h4 className="text-yellow-900 dark:text-yellow-100 mb-2"> (60-79)</h4>
<ul className="text-sm text-yellow-800 dark:text-yellow-200 space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<div className="p-4 bg-red-50 dark:bg-red-950 rounded-lg">
<h4 className="text-red-900 dark:text-red-100 mb-2"> (&lt;60)</h4>
<ul className="text-sm text-red-800 dark:text-red-200 space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</Card>
{/* 多因子综合评价功能说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="mb-2"></p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: pH值7</li>
<li> <strong>(AHP)</strong>: = Σ( × )</li>
<li> <strong></strong>: 0-10085-100</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: 0-100</li>
<li> <strong></strong>: 8060-79&lt;60</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
</ul>
</div>
</div>
</Card>
</div>
{/* 权重配置对话框 */}
<Dialog open={showWeightConfig} onOpenChange={setShowWeightConfig}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 预设方案 */}
<div>
<h4 className="text-sm font-medium mb-3"></h4>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleApplyPreset('default')}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleApplyPreset('grain')}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleApplyPreset('economic')}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleResetWeights}
>
</Button>
</div>
</div>
{/* 权重调整 */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium"></h4>
<span className={`text-sm font-medium ${
totalWeight === 100 ? 'text-green-600' : 'text-red-600'
}`}>
: {totalWeight}%
</span>
</div>
<div className="space-y-4">
{factorWeights.map((factor) => (
<div key={factor.id} className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{factor.name}</span>
<div className="flex items-center gap-2">
<Input
type="number"
value={factor.weight}
onChange={(e) => handleUpdateWeight(factor.id, parseInt(e.target.value) || 0)}
className="w-16 h-8 text-xs"
min={0}
max={100}
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
<Slider
value={[factor.weight]}
onValueChange={([value]) => handleUpdateWeight(factor.id, value)}
max={100}
step={1}
className="h-2"
/>
</div>
))}
</div>
{totalWeight !== 100 && (
<div className="p-3 bg-red-50 dark:bg-red-950 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">
100%{totalWeight}%
</p>
</div>
)}
</div>
{/* 权重说明 */}
<div className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2"></h4>
<div className="space-y-2 text-xs text-blue-800 dark:text-blue-200">
<p> <strong></strong>: 100%</p>
<p> <strong></strong>: </p>
<p> <strong></strong>: </p>
<p> <strong></strong>: </p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* 作物知识库对话框 */}
<Dialog open={showKnowledgeBase} onOpenChange={setShowKnowledgeBase}>
<DialogContent className="max-w-6xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 知识库统计 */}
<div className="grid grid-cols-3 gap-4">
<Card className="p-4 bg-card">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400 mb-1">
{cropKnowledgeBase.length}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="text-center">
<div className="text-2xl font-bold text-green-600 dark:text-green-400 mb-1">
{new Set(cropKnowledgeBase.map(crop => crop.category)).size}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="text-center">
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400 mb-1">
{cropKnowledgeBase.reduce((sum, crop) => sum + crop.riskFactors.length, 0)}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</Card>
</div>
{/* 作物列表 */}
<div className="space-y-4">
{cropKnowledgeBase.map((crop) => (
<Card key={crop.id} className="p-4 bg-card">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h4 className="text-base font-medium">{crop.cropName}</h4>
<Badge variant="outline" className="text-xs">
{crop.category}
</Badge>
<Badge variant="outline" className="text-xs">
{crop.growthCycle.days}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-4">
{crop.description}
</p>
<div className="grid grid-cols-2 gap-4">
{/* 土壤要求 */}
<div>
<h5 className="text-sm font-medium mb-2 flex items-center gap-2">
<Beaker className="w-4 h-4 text-amber-600 dark:text-amber-400" />
</h5>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span>pH值</span>
<span>{crop.soilRequirements.ph.optimal[0]}-{crop.soilRequirements.ph.optimal[1]}</span>
</div>
<div className="flex justify-between text-xs">
<span> (g/kg)</span>
<span>{crop.soilRequirements.organicMatter.optimal[0]}-{crop.soilRequirements.organicMatter.optimal[1]}</span>
</div>
<div className="flex justify-between text-xs">
<span> (cm)</span>
<span>{crop.soilRequirements.soilDepth.optimal[0]}-{crop.soilRequirements.soilDepth.optimal[1]}</span>
</div>
<div className="flex justify-between text-xs">
<span> (g/kg)</span>
<span>{crop.soilRequirements.nitrogen.optimal[0]}-{crop.soilRequirements.nitrogen.optimal[1]}</span>
</div>
<div className="flex justify-between text-xs">
<span> (g/kg)</span>
<span>{crop.soilRequirements.phosphorus.optimal[0]}-{crop.soilRequirements.phosphorus.optimal[1]}</span>
</div>
<div className="flex justify-between text-xs">
<span> (g/kg)</span>
<span>{crop.soilRequirements.potassium.optimal[0]}-{crop.soilRequirements.potassium.optimal[1]}</span>
</div>
</div>
</div>
{/* 气候要求 */}
<div>
<h5 className="text-sm font-medium mb-2 flex items-center gap-2">
<Cloud className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</h5>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span> (°C)</span>
<span>{crop.climateRequirements.temperature.optimal[0]}-{crop.climateRequirements.temperature.optimal[1]}</span>
</div>
<div className="flex justify-between text-xs">
<span> (mm/)</span>
<span>{crop.climateRequirements.rainfall.optimal[0]}-{crop.climateRequirements.rainfall.optimal[1]}</span>
</div>
<div className="flex justify-between text-xs">
<span> (/)</span>
<span>{crop.climateRequirements.sunlight.optimal[0]}-{crop.climateRequirements.sunlight.optimal[1]}</span>
</div>
</div>
{/* 预期产量 */}
<div className="mt-3">
<h5 className="text-sm font-medium mb-2 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />
(kg/)
</h5>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center p-2 bg-green-50 dark:bg-green-950 rounded">
<div className="font-medium text-green-700 dark:text-green-300">
{crop.expectedYield.high[0]}-{crop.expectedYield.high[1]}
</div>
<div className="text-green-600 dark:text-green-400"></div>
</div>
<div className="text-center p-2 bg-blue-50 dark:bg-blue-950 rounded">
<div className="font-medium text-blue-700 dark:text-blue-300">
{crop.expectedYield.medium[0]}-{crop.expectedYield.medium[1]}
</div>
<div className="text-blue-600 dark:text-blue-400"></div>
</div>
<div className="text-center p-2 bg-yellow-50 dark:bg-yellow-950 rounded">
<div className="font-medium text-yellow-700 dark:text-yellow-300">
{crop.expectedYield.low[0]}-{crop.expectedYield.low[1]}
</div>
<div className="text-yellow-600 dark:text-yellow-400"></div>
</div>
</div>
</div>
</div>
</div>
{/* 风险因子 */}
{crop.riskFactors.length > 0 && (
<div className="mt-4">
<h5 className="text-sm font-medium mb-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
</h5>
<div className="grid grid-cols-2 gap-2">
{crop.riskFactors.map((risk) => (
<div key={risk.id} className="p-2 bg-red-50 dark:bg-red-950 rounded">
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full ${
risk.severity === 'high' ? 'bg-red-500' : 'bg-yellow-500'
}`} />
<span className="text-xs font-medium">{risk.name}</span>
</div>
<p className="text-xs text-muted-foreground">{risk.suggestion}</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
</Card>
))}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,565 @@
/**
* 作物知识库服务
* 提供作物-环境适配数据和分析功能
*/
import { Crop, CropRecommendation, FieldFactors, MatchDetail, RiskFactor } from './multiFactorTypes';
// 作物知识库数据
export const cropKnowledgeBase: Crop[] = [
// 粮食作物
{
id: 'rice',
cropName: '水稻',
category: '粮食作物',
description: '水稻是世界上最重要的粮食作物之一,需要充足的水分和温暖的气候条件。',
growthCycle: {
days: 120,
seasons: ['春季', '夏季']
},
soilRequirements: {
ph: { optimal: [5.5, 6.5], acceptable: [5.0, 7.0] },
organicMatter: { optimal: [25, 40], acceptable: [20, 50] },
soilDepth: { optimal: [50, 100], acceptable: [30, 120] },
nitrogen: { optimal: [1.5, 2.5], acceptable: [1.0, 3.0] },
phosphorus: { optimal: [1.0, 2.0], acceptable: [0.5, 2.5] },
potassium: { optimal: [15, 25], acceptable: [10, 30] },
drainage: { optimal: [60, 80], acceptable: [50, 90] }
},
climateRequirements: {
temperature: { optimal: [25, 32], acceptable: [20, 35] },
rainfall: { optimal: [1000, 1500], acceptable: [800, 2000] },
sunlight: { optimal: [6, 8], acceptable: [5, 10] }
},
expectedYield: {
high: [600, 800],
medium: [400, 600],
low: [200, 400]
},
riskFactors: [
{
id: 'rice-cold',
name: '低温冷害',
condition: '温度低于20°C',
suggestion: '选择耐寒品种,采用保温育苗技术',
severity: 'high',
category: 'climate'
},
{
id: 'rice-drought',
name: '干旱胁迫',
condition: '降雨量低于800mm/年',
suggestion: '建设灌溉系统,选用耐旱品种',
severity: 'high',
category: 'climate'
}
]
},
{
id: 'wheat',
cropName: '小麦',
category: '粮食作物',
description: '小麦是温带地区重要的粮食作物,适应性较强,耐寒性较好。',
growthCycle: {
days: 110,
seasons: ['秋季', '春季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.5], acceptable: [5.5, 8.0] },
organicMatter: { optimal: [20, 35], acceptable: [15, 40] },
soilDepth: { optimal: [60, 120], acceptable: [40, 150] },
nitrogen: { optimal: [1.2, 2.0], acceptable: [0.8, 2.5] },
phosphorus: { optimal: [0.8, 1.5], acceptable: [0.5, 2.0] },
potassium: { optimal: [12, 20], acceptable: [8, 25] },
drainage: { optimal: [70, 90], acceptable: [60, 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: [500, 700],
medium: [300, 500],
low: [150, 300]
},
riskFactors: [
{
id: 'wheat-heat',
name: '高温热害',
condition: '温度超过25°C',
suggestion: '选择耐热品种,调整播种期',
severity: 'medium',
category: 'climate'
}
]
},
{
id: 'corn',
cropName: '玉米',
category: '粮食作物',
description: '玉米是重要的粮食和饲料作物,喜温喜光,需肥量大。',
growthCycle: {
days: 130,
seasons: ['春季', '夏季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [25, 40], acceptable: [20, 50] },
soilDepth: { optimal: [80, 150], acceptable: [60, 200] },
nitrogen: { optimal: [1.8, 2.8], acceptable: [1.2, 3.5] },
phosphorus: { optimal: [1.2, 2.2], acceptable: [0.8, 3.0] },
potassium: { optimal: [18, 30], acceptable: [12, 40] },
drainage: { optimal: [80, 95], acceptable: [70, 100] }
},
climateRequirements: {
temperature: { optimal: [22, 30], acceptable: [18, 32] },
rainfall: { optimal: [500, 800], acceptable: [400, 1000] },
sunlight: { optimal: [7, 9], acceptable: [6, 10] }
},
expectedYield: {
high: [700, 900],
medium: [450, 700],
low: [250, 450]
},
riskFactors: [
{
id: 'corn-drought',
name: '干旱胁迫',
condition: '降雨量低于400mm/年',
suggestion: '建设灌溉设施,选用耐旱品种',
severity: 'high',
category: 'climate'
},
{
id: 'corn-pollination',
name: '授粉不良',
condition: '开花期温度超过35°C',
suggestion: '调整播种期,选择耐热品种',
severity: 'medium',
category: 'climate'
}
]
},
// 经济作物
{
id: 'cotton',
cropName: '棉花',
category: '经济作物',
description: '棉花是重要的经济作物,喜温喜光,对土壤要求较高。',
growthCycle: {
days: 150,
seasons: ['春季', '夏季', '秋季']
},
soilRequirements: {
ph: { optimal: [6.5, 8.0], acceptable: [6.0, 8.5] },
organicMatter: { optimal: [20, 35], acceptable: [15, 40] },
soilDepth: { optimal: [100, 180], acceptable: [80, 200] },
nitrogen: { optimal: [1.5, 2.5], acceptable: [1.0, 3.0] },
phosphorus: { optimal: [1.0, 2.0], acceptable: [0.6, 2.5] },
potassium: { optimal: [20, 35], acceptable: [15, 45] },
drainage: { optimal: [85, 95], acceptable: [75, 100] }
},
climateRequirements: {
temperature: { optimal: [25, 32], acceptable: [20, 35] },
rainfall: { optimal: [400, 600], acceptable: [300, 800] },
sunlight: { optimal: [8, 10], acceptable: [7, 12] }
},
expectedYield: {
high: [120, 180],
medium: [80, 120],
low: [40, 80]
},
riskFactors: [
{
id: 'cotton-boll',
name: '蕾铃脱落',
condition: '湿度过大或温差过大',
suggestion: '合理密植,加强田间管理',
severity: 'medium',
category: 'climate'
}
]
},
{
id: 'soybean',
cropName: '大豆',
category: '经济作物',
description: '大豆是重要的油料作物,固氮能力强,适应性较广。',
growthCycle: {
days: 100,
seasons: ['春季', '夏季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [25, 40], acceptable: [20, 50] },
soilDepth: { optimal: [60, 120], acceptable: [40, 150] },
nitrogen: { optimal: [1.2, 2.0], acceptable: [0.8, 2.5] },
phosphorus: { optimal: [1.0, 2.0], acceptable: [0.6, 2.5] },
potassium: { optimal: [15, 25], acceptable: [10, 30] },
drainage: { optimal: [75, 90], acceptable: [65, 100] }
},
climateRequirements: {
temperature: { optimal: [20, 28], acceptable: [15, 32] },
rainfall: { optimal: [500, 700], acceptable: [400, 900] },
sunlight: { optimal: [6, 8], acceptable: [5, 10] }
},
expectedYield: {
high: [250, 350],
medium: [150, 250],
low: [80, 150]
},
riskFactors: [
{
id: 'soybean-flower',
name: '落花落荚',
condition: '高温干旱或连续阴雨',
suggestion: '合理施肥,科学管水',
severity: 'medium',
category: 'climate'
}
]
},
// 蔬菜作物
{
id: 'tomato',
cropName: '番茄',
category: '蔬菜作物',
description: '番茄是重要的蔬菜作物,喜温喜光,对肥水要求较高。',
growthCycle: {
days: 90,
seasons: ['春季', '夏季', '秋季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [30, 45], acceptable: [25, 50] },
soilDepth: { optimal: [40, 80], acceptable: [30, 100] },
nitrogen: { optimal: [2.0, 3.0], acceptable: [1.5, 3.5] },
phosphorus: { optimal: [1.5, 2.5], acceptable: [1.0, 3.0] },
potassium: { optimal: [25, 40], acceptable: [20, 50] },
drainage: { optimal: [80, 95], acceptable: [70, 100] }
},
climateRequirements: {
temperature: { optimal: [22, 28], acceptable: [18, 32] },
rainfall: { optimal: [400, 600], acceptable: [300, 800] },
sunlight: { optimal: [7, 9], acceptable: [6, 10] }
},
expectedYield: {
high: [8000, 12000],
medium: [5000, 8000],
low: [2000, 5000]
},
riskFactors: [
{
id: 'tomato-disease',
name: '病害高发',
condition: '湿度过大,通风不良',
suggestion: '加强病虫害防治,合理密植',
severity: 'high',
category: 'disease'
}
]
},
{
id: 'cucumber',
cropName: '黄瓜',
category: '蔬菜作物',
description: '黄瓜是重要的蔬菜作物,喜温喜湿,生长周期短。',
growthCycle: {
days: 60,
seasons: ['春季', '夏季', '秋季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [30, 45], acceptable: [25, 50] },
soilDepth: { optimal: [30, 60], acceptable: [20, 80] },
nitrogen: { optimal: [2.5, 3.5], acceptable: [2.0, 4.0] },
phosphorus: { optimal: [1.8, 2.8], acceptable: [1.2, 3.5] },
potassium: { optimal: [30, 45], acceptable: [25, 55] },
drainage: { optimal: [85, 95], acceptable: [75, 100] }
},
climateRequirements: {
temperature: { optimal: [22, 30], acceptable: [18, 32] },
rainfall: { optimal: [500, 700], acceptable: [400, 900] },
sunlight: { optimal: [6, 8], acceptable: [5, 10] }
},
expectedYield: {
high: [6000, 9000],
medium: [4000, 6000],
low: [2000, 4000]
},
riskFactors: [
{
id: 'cucumber-pest',
name: '虫害严重',
condition: '温度适宜,湿度大',
suggestion: '加强虫害监测,及时防治',
severity: 'medium',
category: 'pest'
}
]
}
];
/**
* 匹配作物推荐
*/
export function matchCropsForField(fieldFactors: FieldFactors): CropRecommendation[] {
const recommendations: CropRecommendation[] = [];
for (const crop of cropKnowledgeBase) {
const matchDetails: MatchDetail[] = [];
let totalScore = 0;
let factorCount = 0;
// 土壤pH匹配
const phScore = calculateFactorScore(
fieldFactors.ph,
crop.soilRequirements.ph.optimal,
crop.soilRequirements.ph.acceptable
);
matchDetails.push({
factor: 'pH',
value: fieldFactors.ph,
status: getScoreStatus(phScore),
score: phScore
});
totalScore += phScore;
factorCount++;
// 有机质匹配
const organicScore = calculateFactorScore(
fieldFactors.organic,
crop.soilRequirements.organicMatter.optimal,
crop.soilRequirements.organicMatter.acceptable
);
matchDetails.push({
factor: '有机质',
value: fieldFactors.organic,
status: getScoreStatus(organicScore),
score: organicScore
});
totalScore += organicScore;
factorCount++;
// 土层厚度匹配
const depthScore = calculateFactorScore(
fieldFactors.depth,
crop.soilRequirements.soilDepth.optimal,
crop.soilRequirements.soilDepth.acceptable
);
matchDetails.push({
factor: '土层厚度',
value: fieldFactors.depth,
status: getScoreStatus(depthScore),
score: depthScore
});
totalScore += depthScore;
factorCount++;
// 全氮匹配
const nitrogenScore = calculateFactorScore(
fieldFactors.nitrogen,
crop.soilRequirements.nitrogen.optimal,
crop.soilRequirements.nitrogen.acceptable
);
matchDetails.push({
factor: '全氮',
value: fieldFactors.nitrogen,
status: getScoreStatus(nitrogenScore),
score: nitrogenScore
});
totalScore += nitrogenScore;
factorCount++;
// 全磷匹配
const phosphorusScore = calculateFactorScore(
fieldFactors.phosphorus,
crop.soilRequirements.phosphorus.optimal,
crop.soilRequirements.phosphorus.acceptable
);
matchDetails.push({
factor: '全磷',
value: fieldFactors.phosphorus,
status: getScoreStatus(phosphorusScore),
score: phosphorusScore
});
totalScore += phosphorusScore;
factorCount++;
// 全钾匹配
const potassiumScore = calculateFactorScore(
fieldFactors.potassium,
crop.soilRequirements.potassium.optimal,
crop.soilRequirements.potassium.acceptable
);
matchDetails.push({
factor: '全钾',
value: fieldFactors.potassium,
status: getScoreStatus(potassiumScore),
score: potassiumScore
});
totalScore += potassiumScore;
factorCount++;
// 排水性匹配
const drainageScore = calculateFactorScore(
fieldFactors.drainage,
crop.soilRequirements.drainage.optimal,
crop.soilRequirements.drainage.acceptable
);
matchDetails.push({
factor: '排水性',
value: fieldFactors.drainage,
status: getScoreStatus(drainageScore),
score: drainageScore
});
totalScore += drainageScore;
factorCount++;
// 气温匹配(如果有数据)
if (fieldFactors.temperature !== undefined) {
const tempScore = calculateFactorScore(
fieldFactors.temperature,
crop.climateRequirements.temperature.optimal,
crop.climateRequirements.temperature.acceptable
);
matchDetails.push({
factor: '温度',
value: fieldFactors.temperature,
status: getScoreStatus(tempScore),
score: tempScore
});
totalScore += tempScore;
factorCount++;
}
// 降雨匹配(如果有数据)
if (fieldFactors.rainfall !== undefined) {
const rainScore = calculateFactorScore(
fieldFactors.rainfall,
crop.climateRequirements.rainfall.optimal,
crop.climateRequirements.rainfall.acceptable
);
matchDetails.push({
factor: '降雨',
value: fieldFactors.rainfall,
status: getScoreStatus(rainScore),
score: rainScore
});
totalScore += rainScore;
factorCount++;
}
// 计算综合匹配分数
const averageScore = Math.round(totalScore / factorCount);
// 确定适宜性等级
let suitabilityLevel: '高度推荐' | '推荐' | '谨慎种植' | '不推荐';
if (averageScore >= 85) {
suitabilityLevel = '高度推荐';
} else if (averageScore >= 70) {
suitabilityLevel = '推荐';
} else if (averageScore >= 50) {
suitabilityLevel = '谨慎种植';
} else {
suitabilityLevel = '不推荐';
}
// 获取适用风险因子
const applicableRisks = crop.riskFactors.filter(risk => {
if (risk.category === 'climate' && fieldFactors.temperature !== undefined) {
return checkRiskCondition(risk.condition, fieldFactors);
}
// 其他风险因子的判断逻辑
return false;
});
// 确定预期产量
let expectedYield: [number, number];
if (suitabilityLevel === '高度推荐') {
expectedYield = crop.expectedYield.high;
} else if (suitabilityLevel === '推荐') {
expectedYield = crop.expectedYield.medium;
} else {
expectedYield = crop.expectedYield.low;
}
recommendations.push({
crop,
matchScore: averageScore,
suitabilityLevel,
matchDetails,
applicableRisks,
expectedYield
});
}
// 按匹配分数排序
return recommendations.sort((a, b) => b.matchScore - a.matchScore);
}
/**
* 计算因子得分
*/
function calculateFactorScore(
value: number,
optimalRange: [number, number],
acceptableRange: [number, number]
): number {
// 最佳范围100分
if (value >= optimalRange[0] && value <= optimalRange[1]) {
return 100;
}
// 可接受范围60分
if (value >= acceptableRange[0] && value <= acceptableRange[1]) {
return 60;
}
// 偏离程度计算
const optimalMid = (optimalRange[0] + optimalRange[1]) / 2;
const deviation = Math.abs(value - optimalMid);
const maxDeviation = Math.max(
Math.abs(acceptableRange[0] - optimalMid),
Math.abs(acceptableRange[1] - optimalMid)
);
// 根据偏离程度计算分数0-60分
const scoreRatio = Math.max(0, 1 - deviation / maxDeviation);
return Math.round(scoreRatio * 60);
}
/**
* 获取得分状态
*/
function getScoreStatus(score: number): 'optimal' | 'acceptable' | 'poor' {
if (score >= 90) return 'optimal';
if (score >= 60) return 'acceptable';
return 'poor';
}
/**
* 检查风险条件
*/
function checkRiskCondition(condition: string, fieldFactors: FieldFactors): boolean {
// 简化的条件判断逻辑
if (condition.includes('温度低于') && fieldFactors.temperature !== undefined) {
const threshold = parseInt(condition.match(/\d+/)?.[0] || '0');
return fieldFactors.temperature < threshold;
}
if (condition.includes('温度超过') && fieldFactors.temperature !== undefined) {
const threshold = parseInt(condition.match(/\d+/)?.[0] || '0');
return fieldFactors.temperature > threshold;
}
if (condition.includes('降雨量低于') && fieldFactors.rainfall !== undefined) {
const threshold = parseInt(condition.match(/\d+/)?.[0] || '0');
return fieldFactors.rainfall < threshold;
}
return false;
}

View File

@@ -0,0 +1,328 @@
/**
* 多因子评价服务类
* 提供地块适宜性评价、批量分析等功能
*/
import {
EvaluationFactor,
SuitabilityResult,
FactorWeight,
BatchProgress,
DEFAULT_FACTORS,
WEIGHT_PRESETS,
MOCK_FIELDS
} from './multiFactorTypes';
export class MultiFactorService {
/**
* 计算单个因子的得分
*/
static calculateFactorScore(value: number, optimalRange: [number, number]): number {
const [min, max] = optimalRange;
const mid = (min + max) / 2;
const range = max - min;
// 如果在最佳范围内,得分较高
if (value >= min && value <= max) {
// 越接近中值得分越高 (85-100分)
const deviation = Math.abs(value - mid);
const deviationRatio = deviation / (range / 2);
return Math.max(85, 100 - deviationRatio * 15);
}
// 如果在范围外,根据偏离程度降低分数
const deviation = value < min ? min - value : value - max;
const deviationRatio = deviation / range;
// 偏离越大,分数越低 (最低20分)
return Math.max(20, 85 - deviationRatio * 65);
}
/**
* 计算综合评分层次分析法AHP加权求和
*/
static calculateTotalScore(factors: EvaluationFactor[]): number {
let totalScore = 0;
for (const factor of factors) {
totalScore += (factor.score * factor.weight) / 100;
}
return Math.round(totalScore);
}
/**
* 根据总分确定适宜性等级
*/
static getGrade(totalScore: number): '高度适宜' | '一般适宜' | '不适宜' {
if (totalScore >= 80) return '高度适宜';
if (totalScore >= 60) return '一般适宜';
return '不适宜';
}
/**
* 从空间分析服务读取地块因子数据
*/
static fetchFieldFactorsFromSpatialService(fieldId: string, weights: FactorWeight[]): EvaluationFactor[] {
// 模拟读取地块的土壤因子数据
return [
{
id: 'ph',
name: 'pH值',
value: 6.0 + Math.random() * 2, // 6.0-8.0
weight: weights.find(f => f.id === 'ph')?.weight || 20,
unit: '',
optimalRange: [6.5, 7.5],
score: 0
},
{
id: 'organic',
name: '有机质含量',
value: 15 + Math.random() * 20, // 15-35 g/kg
weight: weights.find(f => f.id === 'organic')?.weight || 25,
unit: 'g/kg',
optimalRange: [20, 30],
score: 0
},
{
id: 'depth',
name: '土层厚度',
value: 30 + Math.random() * 70, // 30-100 cm
weight: weights.find(f => f.id === 'depth')?.weight || 20,
unit: 'cm',
optimalRange: [50, 80],
score: 0
},
{
id: 'nitrogen',
name: '全氮',
value: 0.8 + Math.random() * 1.5, // 0.8-2.3 g/kg
weight: weights.find(f => f.id === 'nitrogen')?.weight || 10,
unit: 'g/kg',
optimalRange: [1.0, 2.0],
score: 0
},
{
id: 'phosphorus',
name: '全磷',
value: 0.4 + Math.random() * 1.2, // 0.4-1.6 g/kg
weight: weights.find(f => f.id === 'phosphorus')?.weight || 10,
unit: 'g/kg',
optimalRange: [0.6, 1.2],
score: 0
},
{
id: 'potassium',
name: '全钾',
value: 12 + Math.random() * 13, // 12-25 g/kg
weight: weights.find(f => f.id === 'potassium')?.weight || 10,
unit: 'g/kg',
optimalRange: [15, 20],
score: 0
},
{
id: 'drainage',
name: '排水性',
value: 60 + Math.random() * 40, // 60-100分
weight: weights.find(f => f.id === 'drainage')?.weight || 5,
unit: '',
optimalRange: [70, 90],
score: 0
},
];
}
/**
* 生成地块适宜性评价结果
*/
static generateEvaluationResult(fieldId: string, weights: FactorWeight[]): SuitabilityResult {
const field = MOCK_FIELDS.find(f => f.id === fieldId);
if (!field) {
throw new Error(`Field ${fieldId} not found`);
}
// 从空间分析服务读取因子数据
const factors = MultiFactorService.fetchFieldFactorsFromSpatialService(fieldId, weights);
// 计算每个因子的得分
const scoredFactors = factors.map(factor => ({
...factor,
score: MultiFactorService.calculateFactorScore(factor.value, factor.optimalRange)
}));
// 计算综合得分(加权汇总)
const totalScore = MultiFactorService.calculateTotalScore(scoredFactors);
// 确定适宜性等级
const grade = MultiFactorService.getGrade(totalScore);
return {
fieldId,
fieldName: field.name,
totalScore,
grade,
factors: scoredFactors,
timestamp: new Date().toISOString(),
};
}
/**
* 批量分析所有地块
*/
static async runBatchAnalysis(
weights: FactorWeight[],
onProgress?: (progress: BatchProgress) => void
): Promise<SuitabilityResult[]> {
const totalFields = MOCK_FIELDS.length;
const results: SuitabilityResult[] = [];
let highSuitability = 0;
let mediumSuitability = 0;
let lowSuitability = 0;
for (let i = 0; i < totalFields; i++) {
const field = MOCK_FIELDS[i];
// 模拟处理延迟
await new Promise(resolve => setTimeout(resolve, 100));
// 生成评价结果
const result = MultiFactorService.generateEvaluationResult(field.id, weights);
results.push(result);
// 更新统计数据
if (result.grade === '高度适宜') highSuitability++;
else if (result.grade === '一般适宜') mediumSuitability++;
else lowSuitability++;
// 更新进度
if (onProgress) {
onProgress({
total: totalFields,
processed: i + 1,
highSuitability,
mediumSuitability,
lowSuitability,
currentField: field.name
});
}
}
return results;
}
/**
* 验证权重总和
*/
static validateWeights(weights: FactorWeight[]): { isValid: boolean; totalWeight: number } {
const totalWeight = weights.reduce((sum, f) => sum + f.weight, 0);
return {
isValid: totalWeight === 100,
totalWeight
};
}
/**
* 获取预设权重方案
*/
static getWeightPreset(preset: 'grain' | 'economic' | 'default'): FactorWeight[] {
switch (preset) {
case 'grain':
return [...WEIGHT_PRESETS.grain];
case 'economic':
return [...WEIGHT_PRESETS.economic];
default:
return [...DEFAULT_FACTORS];
}
}
/**
* 更新权重配置
*/
static updateWeight(weights: FactorWeight[], factorId: string, newWeight: number): FactorWeight[] {
return weights.map(f => f.id === factorId ? { ...f, weight: newWeight } : f);
}
/**
* 重置权重到默认值
*/
static resetWeights(): FactorWeight[] {
return [...DEFAULT_FACTORS];
}
/**
* 生成模拟评价数据(用于演示)
*/
static generateMockEvaluationData(): SuitabilityResult[] {
return [
{
fieldId: 'field-1',
fieldName: '东区1号地',
totalScore: 87,
grade: '高度适宜',
factors: [
{ id: 'ph', name: 'pH值', value: 6.5, weight: 20, unit: '', optimalRange: [6.0, 7.5], score: 95 },
{ id: 'organic', name: '有机质含量', value: 32, weight: 25, unit: 'g/kg', optimalRange: [25, 40], score: 90 },
{ id: 'depth', name: '土层厚度', value: 85, weight: 20, unit: 'cm', optimalRange: [60, 100], score: 88 },
{ id: 'nitrogen', name: '全氮', value: 1.8, weight: 10, unit: 'g/kg', optimalRange: [1.5, 2.5], score: 85 },
{ id: 'phosphorus', name: '全磷', value: 1.2, weight: 10, unit: 'g/kg', optimalRange: [1.0, 2.0], score: 80 },
{ id: 'potassium', name: '全钾', value: 18, weight: 10, unit: 'g/kg', optimalRange: [15, 25], score: 82 },
{ id: 'drainage', name: '排水性', value: 4, weight: 5, unit: '', optimalRange: [3, 5], score: 90 },
],
timestamp: '2024-10-15 14:30',
},
{
fieldId: 'field-2',
fieldName: '西区2号地',
totalScore: 72,
grade: '一般适宜',
factors: [
{ id: 'ph', name: 'pH值', value: 7.8, weight: 20, unit: '', optimalRange: [6.0, 7.5], score: 65 },
{ id: 'organic', name: '有机质含量', value: 22, weight: 25, unit: 'g/kg', optimalRange: [25, 40], score: 70 },
{ id: 'depth', name: '土层厚度', value: 55, weight: 20, unit: 'cm', optimalRange: [60, 100], score: 68 },
{ id: 'nitrogen', name: '全氮', value: 1.3, weight: 10, unit: 'g/kg', optimalRange: [1.5, 2.5], score: 72 },
{ id: 'phosphorus', name: '全磷', value: 0.9, weight: 10, unit: 'g/kg', optimalRange: [1.0, 2.0], score: 75 },
{ id: 'potassium', name: '全钾', value: 14, weight: 10, unit: 'g/kg', optimalRange: [15, 25], score: 78 },
{ id: 'drainage', name: '排水性', value: 3, weight: 5, unit: '', optimalRange: [3, 5], score: 85 },
],
timestamp: '2024-10-15 14:28',
},
{
fieldId: 'field-3',
fieldName: '南区3号地',
totalScore: 58,
grade: '不适宜',
factors: [
{ id: 'ph', name: 'pH值', value: 8.5, weight: 20, unit: '', optimalRange: [6.0, 7.5], score: 45 },
{ id: 'organic', name: '有机质含量', value: 15, weight: 25, unit: 'g/kg', optimalRange: [25, 40], score: 52 },
{ id: 'depth', name: '土层厚度', value: 42, weight: 20, unit: 'cm', optimalRange: [60, 100], score: 55 },
{ id: 'nitrogen', name: '全氮', value: 0.8, weight: 10, unit: 'g/kg', optimalRange: [1.5, 2.5], score: 60 },
{ id: 'phosphorus', name: '全磷', value: 0.6, weight: 10, unit: 'g/kg', optimalRange: [1.0, 2.0], score: 65 },
{ id: 'potassium', name: '全钾', value: 10, weight: 10, unit: 'g/kg', optimalRange: [15, 25], score: 58 },
{ id: 'drainage', name: '排水性', value: 2, weight: 5, unit: '', optimalRange: [3, 5], score: 70 },
],
timestamp: '2024-10-15 14:25',
},
];
}
/**
* 生成更多模拟地块数据(用于批量分析演示)
*/
static generateExtendedMockFields(): Array<{ id: string; name: string; code: string; area: number }> {
const extendedFields = [...MOCK_FIELDS];
// 生成更多模拟地块
for (let i = 4; i <= 68; i++) {
const zone = ['东区', '西区', '南区', '北区', '中心区'][Math.floor(Math.random() * 5)];
const number = Math.floor(Math.random() * 20) + 1;
const area = Math.round((Math.random() * 100 + 50) * 10) / 10;
extendedFields.push({
id: `field-${i}`,
name: `${zone}${number}号地`,
code: `DB${String(i).padStart(3, '0')}`,
area
});
}
return extendedFields;
}
}

View File

@@ -0,0 +1,192 @@
/**
* 多因子评价类型定义
*/
export interface EvaluationFactor {
id: string;
name: string;
value: number;
weight: number;
unit: string;
optimalRange: [number, number];
score: number;
}
export interface SuitabilityResult {
fieldId: string;
fieldName: string;
totalScore: number;
grade: '高度适宜' | '一般适宜' | '不适宜';
factors: EvaluationFactor[];
timestamp: string;
}
export interface FactorWeight {
id: string;
name: string;
weight: number;
unit: string;
}
export interface BatchProgress {
total: number;
processed: number;
highSuitability: number;
mediumSuitability: number;
lowSuitability: number;
currentField: string;
}
// 作物相关类型
export interface Crop {
id: string;
cropName: string;
category: '粮食作物' | '经济作物' | '蔬菜作物' | '果树作物';
description: string;
growthCycle: {
days: number;
seasons: string[];
};
soilRequirements: {
ph: { optimal: [number, number]; acceptable: [number, number] };
organicMatter: { optimal: [number, number]; acceptable: [number, number] };
soilDepth: { optimal: [number, number]; acceptable: [number, number] };
nitrogen: { optimal: [number, number]; acceptable: [number, number] };
phosphorus: { optimal: [number, number]; acceptable: [number, number] };
potassium: { optimal: [number, number]; acceptable: [number, number] };
drainage: { optimal: [number, number]; acceptable: [number, number] };
};
climateRequirements: {
temperature: { optimal: [number, number]; acceptable: [number, number] };
rainfall: { optimal: [number, number]; acceptable: [number, number] };
sunlight: { optimal: [number, number]; acceptable: [number, number] };
};
expectedYield: {
high: [number, number];
medium: [number, number];
low: [number, number];
};
riskFactors: RiskFactor[];
}
export interface RiskFactor {
id: string;
name: string;
condition: string;
suggestion: string;
severity: 'high' | 'medium' | 'low';
category: 'soil' | 'climate' | 'nutrient' | 'disease' | 'pest';
}
export interface CropRecommendation {
crop: Crop;
matchScore: number;
suitabilityLevel: '高度推荐' | '推荐' | '谨慎种植' | '不推荐';
matchDetails: MatchDetail[];
applicableRisks: RiskFactor[];
expectedYield: [number, number];
}
export interface MatchDetail {
factor: string;
value: number;
status: 'optimal' | 'acceptable' | 'poor';
score: number;
}
export interface FieldFactors {
ph: number;
organic: number;
depth: number;
nitrogen: number;
phosphorus: number;
potassium: number;
drainage: number;
temperature?: number;
rainfall?: number;
sunlight?: number;
}
// 评价因子配置
export const DEFAULT_FACTORS: FactorWeight[] = [
{ id: 'ph', name: 'pH值', weight: 20, unit: '' },
{ id: 'organic', name: '有机质含量', weight: 25, unit: 'g/kg' },
{ id: 'depth', name: '土层厚度', weight: 20, unit: 'cm' },
{ id: 'nitrogen', name: '全氮', weight: 10, unit: 'g/kg' },
{ id: 'phosphorus', name: '全磷', weight: 10, unit: 'g/kg' },
{ id: 'potassium', name: '全钾', weight: 10, unit: 'g/kg' },
{ id: 'drainage', name: '排水性', weight: 5, unit: '' },
];
// 预设权重方案
export const WEIGHT_PRESETS = {
grain: [
{ id: 'ph', name: 'pH值', weight: 20, unit: '' },
{ id: 'organic', name: '有机质含量', weight: 30, unit: 'g/kg' },
{ id: 'depth', name: '土层厚度', weight: 25, unit: 'cm' },
{ id: 'nitrogen', name: '全氮', weight: 8, unit: 'g/kg' },
{ id: 'phosphorus', name: '全磷', weight: 8, unit: 'g/kg' },
{ id: 'potassium', name: '全钾', weight: 8, unit: 'g/kg' },
{ id: 'drainage', name: '排水性', weight: 1, unit: '' },
],
economic: [
{ id: 'ph', name: 'pH值', weight: 25, unit: '' },
{ id: 'organic', name: '有机质含量', weight: 35, unit: 'g/kg' },
{ id: 'depth', name: '土层厚度', weight: 15, unit: 'cm' },
{ id: 'nitrogen', name: '全氮', weight: 7, unit: 'g/kg' },
{ id: 'phosphorus', name: '全磷', weight: 7, unit: 'g/kg' },
{ id: 'potassium', name: '全钾', weight: 7, unit: 'g/kg' },
{ id: 'drainage', name: '排水性', weight: 4, unit: '' },
]
};
// 地块模拟数据
export const MOCK_FIELDS = [
{ id: 'field-1', name: '东区1号地', code: 'DB001', area: 85.5 },
{ id: 'field-2', name: '西区2号地', code: 'DB002', area: 92.3 },
{ id: 'field-3', name: '南区3号地', code: 'DB003', area: 78.7 },
];
// 工具函数
export const getGradeColor = (grade: string): string => {
switch (grade) {
case '高度适宜': return '#22c55e';
case '一般适宜': return '#eab308';
case '不适宜': return '#ef4444';
default: return '#6b7280';
}
};
export const getScoreColor = (score: number): string => {
if (score >= 80) return '#22c55e';
if (score >= 60) return '#eab308';
return '#ef4444';
};
export const getSuitabilityLevelColor = (level: string): {
bg: string;
border: string;
text: string;
} => {
switch (level) {
case '高度推荐':
return { bg: '#22c55e', border: '#22c55e', text: '#22c55e' };
case '推荐':
return { bg: '#3b82f6', border: '#3b82f6', text: '#3b82f6' };
case '谨慎种植':
return { bg: '#eab308', border: '#eab308', text: '#eab308' };
default:
return { bg: '#6b7280', border: '#6b7280', text: '#6b7280' };
}
};
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};

View File

@@ -0,0 +1,7 @@
'use client';
import { MultiFactorEvaluation } from './components/MultiFactorEvaluation';
export default function ComprehensivePage() {
return <MultiFactorEvaluation />;
}

View File

@@ -0,0 +1,441 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Leaf, AlertTriangle, ThermometerSun, Cloud, Sun } from 'lucide-react';
import { CropRecommendationState, SuitabilityResult } from './cropRecommendReducer';
// 模拟作物知识库数据
const cropKnowledgeBase = [
{
id: 'wheat',
cropName: '小麦',
category: '粮食作物',
description: '适应性强的主粮作物,对土壤要求较宽泛,耐寒性好,适合北方地区种植。',
growthCycle: {
days: 220,
seasons: ['春季', '秋季']
},
soilRequirements: {
ph: { optimal: [6.5, 7.5], acceptable: [6.0, 8.0] },
organicMatter: { optimal: [25, 35], acceptable: [20, 40] },
soilDepth: { optimal: [60, 100], acceptable: [40, 120] },
nitrogen: { optimal: [1.5, 2.5], acceptable: [1.0, 3.0] },
phosphorus: { optimal: [1.0, 2.0], acceptable: [0.6, 2.5] },
potassium: { optimal: [15, 25], acceptable: [10, 30] },
drainage: { optimal: [3, 5], acceptable: [2, 5] }
},
climateRequirements: {
temperature: { optimal: [15, 22], acceptable: [10, 25] },
rainfall: { optimal: [400, 600], acceptable: [300, 800] },
sunlight: { optimal: [6, 8], acceptable: [5, 10] }
},
expectedYield: {
high: [400, 500],
medium: [300, 400],
low: [200, 300]
},
riskFactors: [
{
id: 'wheat-rust',
name: '锈病风险',
condition: '湿度过高、温度适宜',
severity: 'medium' as const,
suggestion: '选择抗病品种,合理密植,及时防治'
},
{
id: 'wheat-drought',
name: '干旱风险',
condition: '降雨量不足400mm',
severity: 'high' as const,
suggestion: '加强灌溉设施建设,选择抗旱品种'
}
]
},
{
id: 'corn',
cropName: '玉米',
category: '粮食作物',
description: '高产作物,对温度要求较高,需水量大,适合水热条件良好的地区。',
growthCycle: {
days: 120,
seasons: ['春季', '夏季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [30, 40], acceptable: [25, 45] },
soilDepth: { optimal: [80, 120], acceptable: [60, 150] },
nitrogen: { optimal: [2.0, 3.0], acceptable: [1.5, 3.5] },
phosphorus: { optimal: [1.2, 2.5], acceptable: [0.8, 3.0] },
potassium: { optimal: [20, 30], acceptable: [15, 35] },
drainage: { optimal: [3, 5], acceptable: [2, 5] }
},
climateRequirements: {
temperature: { optimal: [20, 28], acceptable: [15, 32] },
rainfall: { optimal: [500, 800], acceptable: [400, 1000] },
sunlight: { optimal: [7, 9], acceptable: [6, 10] }
},
expectedYield: {
high: [600, 800],
medium: [400, 600],
low: [250, 400]
},
riskFactors: [
{
id: 'corn-borer',
name: '玉米螟',
condition: '温度适宜、湿度适中',
severity: 'medium' as const,
suggestion: '生物防治与化学防治结合,适时播种'
},
{
id: 'corn-drought',
name: '花期干旱',
condition: '开花期降雨不足',
severity: 'high' as const,
suggestion: '保证花期灌溉,选择耐旱品种'
}
]
},
{
id: 'soybean',
cropName: '大豆',
category: '经济作物',
description: '豆科作物,具有固氮能力,对土壤肥力要求较低,适合轮作种植。',
growthCycle: {
days: 100,
seasons: ['春季', '夏季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [25, 35], acceptable: [20, 40] },
soilDepth: { optimal: [50, 80], acceptable: [40, 100] },
nitrogen: { optimal: [1.0, 2.0], acceptable: [0.5, 2.5] },
phosphorus: { optimal: [0.8, 1.8], acceptable: [0.5, 2.5] },
potassium: { optimal: [15, 25], acceptable: [10, 30] },
drainage: { optimal: [3, 5], acceptable: [2, 5] }
},
climateRequirements: {
temperature: { optimal: [18, 25], acceptable: [15, 28] },
rainfall: { optimal: [450, 700], acceptable: [350, 900] },
sunlight: { optimal: [6, 8], acceptable: [5, 9] }
},
expectedYield: {
high: [250, 350],
medium: [180, 250],
low: [120, 180]
},
riskFactors: [
{
id: 'soybean-disease',
name: '病害风险',
condition: '高温高湿环境',
severity: 'medium' as const,
suggestion: '选择抗病品种,合理轮作,加强田间管理'
}
]
}
];
interface CropRecommendationsProps {
state: CropRecommendationState;
currentResult: SuitabilityResult;
}
export function CropRecommendations({ state, currentResult }: CropRecommendationsProps) {
// 匹配作物推荐
const matchCropsForField = (fieldFactors: any) => {
return cropKnowledgeBase.map(crop => {
let totalScore = 0;
let factorCount = 0;
const matchDetails: any[] = [];
// 评估土壤因子匹配度
Object.entries(crop.soilRequirements).forEach(([factor, requirements]: [string, any]) => {
if (fieldFactors[factor]) {
const value = fieldFactors[factor];
const { optimal, acceptable } = requirements;
let score = 0;
let status = '偏离';
if (value >= optimal[0] && value <= optimal[1]) {
score = 100;
status = '最佳';
} else if (value >= acceptable[0] && value <= acceptable[1]) {
const deviation = Math.min(
Math.abs(value - optimal[0]),
Math.abs(value - optimal[1])
);
const range = optimal[1] - optimal[0];
score = Math.max(60, 100 - (deviation / range) * 40);
status = '可接受';
} else {
score = Math.max(0, 60 - Math.min(
Math.abs(value - acceptable[0]),
Math.abs(value - acceptable[1])
) * 2);
}
totalScore += score;
factorCount++;
matchDetails.push({
factor: factor === 'ph' ? 'pH值' :
factor === 'organicMatter' ? '有机质' :
factor === 'soilDepth' ? '土层厚度' :
factor === 'nitrogen' ? '全氮' :
factor === 'phosphorus' ? '全磷' :
factor === 'potassium' ? '全钾' : '排水性',
value,
score,
status
});
}
});
// 评估气候因子(简化处理)
if (fieldFactors.temperature) {
const tempScore = fieldFactors.temperature >= 18 && fieldFactors.temperature <= 25 ? 90 : 70;
totalScore += tempScore;
factorCount++;
}
if (fieldFactors.rainfall) {
const rainScore = fieldFactors.rainfall >= 500 && fieldFactors.rainfall <= 800 ? 90 : 70;
totalScore += rainScore;
factorCount++;
}
const matchScore = Math.round(totalScore / factorCount);
// 确定适宜性等级
let suitabilityLevel = '不推荐';
if (matchScore >= 85) suitabilityLevel = '高度推荐';
else if (matchScore >= 70) suitabilityLevel = '推荐';
else if (matchScore >= 50) suitabilityLevel = '谨慎种植';
// 根据适宜性等级选择产量区间
let expectedYield = crop.expectedYield.low;
if (suitabilityLevel === '高度推荐') expectedYield = crop.expectedYield.high;
else if (suitabilityLevel === '推荐') expectedYield = crop.expectedYield.medium;
// 识别适用风险
const applicableRisks = crop.riskFactors.filter(risk => {
if (risk.id.includes('drought') && fieldFactors.rainfall < 400) return true;
if (risk.id.includes('rust') && fieldFactors.temperature >= 15 && fieldFactors.temperature <= 22) return true;
return true; // 简化处理,默认显示所有风险
});
return {
crop,
matchScore,
suitabilityLevel,
matchDetails,
applicableRisks,
expectedYield
};
}).sort((a, b) => b.matchScore - a.matchScore);
};
// 获取地块因子数据
const fieldFactors = {
ph: currentResult.factors.find(f => f.id === 'ph')?.value || 0,
organic: currentResult.factors.find(f => f.id === 'organic')?.value || 0,
depth: currentResult.factors.find(f => f.id === 'depth')?.value || 0,
nitrogen: currentResult.factors.find(f => f.id === 'nitrogen')?.value || 0,
phosphorus: currentResult.factors.find(f => f.id === 'phosphorus')?.value || 0,
potassium: currentResult.factors.find(f => f.id === 'potassium')?.value || 0,
drainage: currentResult.factors.find(f => f.id === 'drainage')?.value || 0,
temperature: 22, // 模拟年均温度
rainfall: 800, // 模拟年均降雨量
};
// 匹配推荐作物
const recommendations = matchCropsForField(fieldFactors);
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="flex items-center gap-2">
<Leaf className="w-5 h-5 text-green-600" />
</h3>
<Badge variant="outline" className="text-xs">
{cropKnowledgeBase.length}
</Badge>
</div>
<div className="space-y-4">
{recommendations.map((recommendation, index) => {
const { crop, matchScore, suitabilityLevel, matchDetails, applicableRisks, expectedYield } = recommendation;
// 根据适宜性等级设置颜色
const levelColor =
suitabilityLevel === '高度推荐' ? { bg: 'bg-green-500', border: '#22c55e', text: 'text-green-600 dark:text-green-400' } :
suitabilityLevel === '推荐' ? { bg: 'bg-blue-500', border: '#3b82f6', text: 'text-blue-600 dark:text-blue-400' } :
suitabilityLevel === '谨慎种植' ? { bg: 'bg-yellow-500', border: '#eab308', text: 'text-yellow-600 dark:text-yellow-400' } :
{ bg: 'bg-gray-500', border: '#6b7280', text: 'text-gray-600 dark:text-gray-400' };
// 只显示高度推荐和推荐的作物
if (suitabilityLevel === '不推荐') return null;
return (
<Card key={index} className="p-5 border-l-4 hover:shadow-lg transition-shadow" style={{ borderLeftColor: levelColor.border }}>
{/* 标题栏 */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900`}>
<Leaf className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="mb-1">{crop.cropName}</h4>
<Badge variant="outline" className="text-xs">{crop.category}</Badge>
</div>
<div className="flex items-center gap-2">
<Badge className={`${levelColor.bg} text-white`}>
{suitabilityLevel}
</Badge>
<span className="text-xs text-muted-foreground">: {matchScore}</span>
</div>
</div>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className={`text-lg font-medium ${levelColor.text}`}>
{expectedYield[0]}-{expectedYield[1]} kg/
</p>
<p className="text-xs text-muted-foreground mt-1">: {crop.growthCycle.days}</p>
</div>
</div>
{/* 作物描述 */}
<p className="text-sm text-muted-foreground mb-3 p-2 bg-gray-50 dark:bg-gray-900 rounded">
{crop.description}
</p>
{/* 土壤因子匹配详情 */}
<div className="mb-3">
<p className="text-xs text-muted-foreground mb-2"></p>
<div className="grid grid-cols-7 gap-2">
{matchDetails.map((detail, i) => (
<div key={i} className="p-2 bg-gray-50 dark:bg-gray-900 rounded text-center">
<p className="text-xs text-muted-foreground mb-1">{detail.factor}</p>
<p className="text-xs font-medium mb-1">{detail.value.toFixed(1)}</p>
{detail.status === '最佳' ? (
<Badge className="bg-green-500 text-white" style={{ fontSize: '9px', padding: '1px 4px' }}>
</Badge>
) : detail.status === '可接受' ? (
<Badge className="bg-blue-500 text-white" style={{ fontSize: '9px', padding: '1px 4px' }}>
</Badge>
) : (
<Badge variant="outline" className="text-red-500" style={{ fontSize: '9px', padding: '1px 4px' }}>
</Badge>
)}
</div>
))}
</div>
</div>
{/* 气候要求 */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded-lg">
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 flex items-center gap-1">
<ThermometerSun className="w-3 h-3" />
</p>
<p className="text-sm text-blue-900 dark:text-blue-100">
{crop.climateRequirements.temperature.optimal[0]}-{crop.climateRequirements.temperature.optimal[1]}°C
</p>
</div>
<div className="p-2 bg-cyan-50 dark:bg-cyan-950 rounded-lg">
<p className="text-xs text-cyan-600 dark:text-cyan-400 mb-1 flex items-center gap-1">
<Cloud className="w-3 h-3" />
</p>
<p className="text-sm text-cyan-900 dark:text-cyan-100">
{crop.climateRequirements.rainfall.optimal[0]}-{crop.climateRequirements.rainfall.optimal[1]}mm/
</p>
</div>
<div className="p-2 bg-amber-50 dark:bg-amber-950 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400 mb-1 flex items-center gap-1">
<Sun className="w-3 h-3" />
</p>
<p className="text-sm text-amber-900 dark:text-amber-100">
{crop.climateRequirements.sunlight.optimal[0]}-{crop.climateRequirements.sunlight.optimal[1]}/
</p>
</div>
</div>
{/* 风险提示 */}
{applicableRisks.length > 0 && (
<div className={`p-3 rounded-lg border ${
applicableRisks.some(r => r.severity === 'high')
? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800'
: 'bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800'
}`}>
<p className="text-xs font-medium mb-2 flex items-center gap-1">
<AlertTriangle className={`w-4 h-4 ${
applicableRisks.some(r => r.severity === 'high')
? 'text-red-600 dark:text-red-400'
: 'text-orange-600 dark:text-orange-400'
}`} />
<span className={applicableRisks.some(r => r.severity === 'high') ? 'text-red-900 dark:text-red-100' : 'text-orange-900 dark:text-orange-100'}>
</span>
</p>
<div className="space-y-2">
{applicableRisks.map((risk, i) => (
<div key={i} className="text-xs">
<div className="flex items-start gap-2">
<Badge
className={
risk.severity === 'high' ? 'bg-red-500 text-white' :
risk.severity === 'medium' ? 'bg-orange-500 text-white' :
'bg-yellow-500 text-white'
}
style={{ fontSize: '9px', padding: '2px 6px', marginTop: '2px' }}
>
{risk.severity === 'high' ? '高风险' : risk.severity === 'medium' ? '中风险' : '低风险'}
</Badge>
<div className="flex-1">
<p className={`font-medium mb-0.5 ${
applicableRisks.some(r => r.severity === 'high')
? 'text-red-800 dark:text-red-200'
: 'text-orange-800 dark:text-orange-200'
}`}>
{risk.name}
</p>
<p className="text-muted-foreground mb-1">: {risk.condition}</p>
<p className={applicableRisks.some(r => r.severity === 'high') ? 'text-red-700 dark:text-red-300' : 'text-orange-700 dark:text-orange-300'}>
💡 {risk.suggestion}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 适宜季节 */}
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>:</span>
{crop.growthCycle.seasons.map((season, i) => (
<Badge key={i} variant="outline" className="text-xs">
{season}
</Badge>
))}
</div>
</Card>
);
})}
</div>
</Card>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ThermometerSun, Leaf, Droplet } from 'lucide-react';
import { SuitabilityResult } from './cropRecommendReducer';
interface FieldEnvironmentOverviewProps {
currentResult: SuitabilityResult;
}
export function FieldEnvironmentOverview({ currentResult }: FieldEnvironmentOverviewProps) {
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-green-600 dark:text-green-400';
if (score >= 60) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
};
const getGradeColor = (grade: string) => {
switch (grade) {
case '高度适宜': return 'bg-green-500';
case '一般适宜': return 'bg-yellow-500';
case '不适宜': return 'bg-red-500';
default: return 'bg-gray-500';
}
};
return (
<Card className="p-6">
<h3 className="mb-4 flex items-center gap-2">
<Leaf className="w-5 h-5 text-green-600" />
{currentResult.fieldName} -
</h3>
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 rounded-lg">
<p className="text-xs text-blue-600 dark:text-blue-400 mb-1 flex items-center gap-1">
<ThermometerSun className="w-3 h-3" />
pH值
</p>
<p className="text-xl font-medium text-blue-900 dark:text-blue-100">
{currentResult.factors.find(f => f.id === 'ph')?.value.toFixed(1)}
</p>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
: {currentResult.factors.find(f => f.id === 'ph')?.optimalRange[0]}-{currentResult.factors.find(f => f.id === 'ph')?.optimalRange[1]}
</p>
</div>
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg">
<p className="text-xs text-green-600 dark:text-green-400 mb-1 flex items-center gap-1">
<Leaf className="w-3 h-3" />
</p>
<p className="text-xl font-medium text-green-900 dark:text-green-100">
{currentResult.factors.find(f => f.id === 'organic')?.value.toFixed(1)} g/kg
</p>
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
: {currentResult.factors.find(f => f.id === 'organic')?.optimalRange[0]}-{currentResult.factors.find(f => f.id === 'organic')?.optimalRange[1]} g/kg
</p>
</div>
<div className="p-4 bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-950 dark:to-amber-900 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400 mb-1"></p>
<p className="text-xl font-medium text-amber-900 dark:text-amber-100">
{currentResult.factors.find(f => f.id === 'depth')?.value.toFixed(0)} cm
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
: {currentResult.factors.find(f => f.id === 'depth')?.optimalRange[0]}-{currentResult.factors.find(f => f.id === 'depth')?.optimalRange[1]} cm
</p>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 rounded-lg">
<p className="text-xs text-purple-600 dark:text-purple-400 mb-1 flex items-center gap-1">
<Droplet className="w-3 h-3" />
</p>
<p className="text-xl font-medium text-purple-900 dark:text-purple-100">
{currentResult.factors.find(f => f.id === 'drainage')?.value.toFixed(0)}
</p>
<p className="text-xs text-purple-700 dark:text-purple-300 mt-1">
: {currentResult.factors.find(f => f.id === 'drainage')?.optimalRange[0]}-{currentResult.factors.find(f => f.id === 'drainage')?.optimalRange[1]}
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{currentResult.factors.find(f => f.id === 'nitrogen')?.value.toFixed(2)} g/kg</p>
<p className="text-xs text-muted-foreground">
: {currentResult.factors.find(f => f.id === 'nitrogen')?.optimalRange[0]}-{currentResult.factors.find(f => f.id === 'nitrogen')?.optimalRange[1]} g/kg
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{currentResult.factors.find(f => f.id === 'phosphorus')?.value.toFixed(2)} g/kg</p>
<p className="text-xs text-muted-foreground">
: {currentResult.factors.find(f => f.id === 'phosphorus')?.optimalRange[0]}-{currentResult.factors.find(f => f.id === 'phosphorus')?.optimalRange[1]} g/kg
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{currentResult.factors.find(f => f.id === 'potassium')?.value.toFixed(1)} g/kg</p>
<p className="text-xs text-muted-foreground">
: {currentResult.factors.find(f => f.id === 'potassium')?.optimalRange[0]}-{currentResult.factors.find(f => f.id === 'potassium')?.optimalRange[1]} g/kg
</p>
</div>
</div>
<div className="p-4 bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950 rounded-lg border border-green-200 dark:border-green-800">
<h4 className="text-green-900 dark:text-green-100 mb-3 font-medium"></h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-green-800 dark:text-green-200 mb-2">
<span className="font-medium"></span>
<span className={`ml-2 text-lg font-bold ${getScoreColor(currentResult.totalScore)}`}>
{currentResult.totalScore}
</span>
</p>
<Badge className={`${getGradeColor(currentResult.grade)} text-white`}>
{currentResult.grade}
</Badge>
</div>
<div>
<p className="text-green-800 dark:text-green-200 mb-1">
<span className="font-medium"></span>
<span className="text-green-600 dark:text-green-400 font-medium ml-1">
{currentResult.factors.filter(f => f.score >= 80).length}
</span>
</p>
<p className="text-green-800 dark:text-green-200">
<span className="font-medium"></span>
<span className="text-red-600 dark:text-red-400 font-medium ml-1">
{currentResult.factors.filter(f => f.score < 70).length}
</span>
</p>
</div>
<div>
<p className="text-green-800 dark:text-green-200 mb-1">
<span className="font-medium"></span>
<span className="text-muted-foreground ml-1">{currentResult.timestamp}</span>
</p>
<p className="text-green-800 dark:text-green-200">
<span className="font-medium">ID</span>
<span className="text-muted-foreground ml-1">{currentResult.fieldId}</span>
</p>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,409 @@
'use client';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { BookOpen, Database, Leaf, Target, Droplet, Cloud, Sun, ThermometerSun, TrendingUp, AlertTriangle } from 'lucide-react';
// 作物知识库数据与CropRecommendations.tsx保持一致
const cropKnowledgeBase = [
{
id: 'wheat',
cropName: '小麦',
category: '粮食作物',
description: '适应性强的主粮作物,对土壤要求较宽泛,耐寒性好,适合北方地区种植。',
growthCycle: {
days: 220,
seasons: ['春季', '秋季']
},
soilRequirements: {
ph: { optimal: [6.5, 7.5], acceptable: [6.0, 8.0] },
organicMatter: { optimal: [25, 35], acceptable: [20, 40] },
soilDepth: { optimal: [60, 100], acceptable: [40, 120] },
nitrogen: { optimal: [1.5, 2.5], acceptable: [1.0, 3.0] },
phosphorus: { optimal: [1.0, 2.0], acceptable: [0.6, 2.5] },
potassium: { optimal: [15, 25], acceptable: [10, 30] },
drainage: { optimal: [3, 5], acceptable: [2, 5] }
},
climateRequirements: {
temperature: { optimal: [15, 22], acceptable: [10, 25] },
rainfall: { optimal: [400, 600], acceptable: [300, 800] },
sunlight: { optimal: [6, 8], acceptable: [5, 10] }
},
expectedYield: {
high: [400, 500],
medium: [300, 400],
low: [200, 300]
},
riskFactors: [
{
id: 'wheat-rust',
name: '锈病风险',
condition: '湿度过高、温度适宜',
severity: 'medium' as const,
suggestion: '选择抗病品种,合理密植,及时防治'
},
{
id: 'wheat-drought',
name: '干旱风险',
condition: '降雨量不足400mm',
severity: 'high' as const,
suggestion: '加强灌溉设施建设,选择抗旱品种'
}
]
},
{
id: 'corn',
cropName: '玉米',
category: '粮食作物',
description: '高产作物,对温度要求较高,需水量大,适合水热条件良好的地区。',
growthCycle: {
days: 120,
seasons: ['春季', '夏季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [30, 40], acceptable: [25, 45] },
soilDepth: { optimal: [80, 120], acceptable: [60, 150] },
nitrogen: { optimal: [2.0, 3.0], acceptable: [1.5, 3.5] },
phosphorus: { optimal: [1.2, 2.5], acceptable: [0.8, 3.0] },
potassium: { optimal: [20, 30], acceptable: [15, 35] },
drainage: { optimal: [3, 5], acceptable: [2, 5] }
},
climateRequirements: {
temperature: { optimal: [20, 28], acceptable: [15, 32] },
rainfall: { optimal: [500, 800], acceptable: [400, 1000] },
sunlight: { optimal: [7, 9], acceptable: [6, 10] }
},
expectedYield: {
high: [600, 800],
medium: [400, 600],
low: [250, 400]
},
riskFactors: [
{
id: 'corn-borer',
name: '玉米螟',
condition: '温度适宜、湿度适中',
severity: 'medium' as const,
suggestion: '生物防治与化学防治结合,适时播种'
},
{
id: 'corn-drought',
name: '花期干旱',
condition: '开花期降雨不足',
severity: 'high' as const,
suggestion: '保证花期灌溉,选择耐旱品种'
}
]
},
{
id: 'soybean',
cropName: '大豆',
category: '经济作物',
description: '豆科作物,具有固氮能力,对土壤肥力要求较低,适合轮作种植。',
growthCycle: {
days: 100,
seasons: ['春季', '夏季']
},
soilRequirements: {
ph: { optimal: [6.0, 7.0], acceptable: [5.5, 7.5] },
organicMatter: { optimal: [25, 35], acceptable: [20, 40] },
soilDepth: { optimal: [50, 80], acceptable: [40, 100] },
nitrogen: { optimal: [1.0, 2.0], acceptable: [0.5, 2.5] },
phosphorus: { optimal: [0.8, 1.8], acceptable: [0.5, 2.5] },
potassium: { optimal: [15, 25], acceptable: [10, 30] },
drainage: { optimal: [3, 5], acceptable: [2, 5] }
},
climateRequirements: {
temperature: { optimal: [18, 25], acceptable: [15, 28] },
rainfall: { optimal: [450, 700], acceptable: [350, 900] },
sunlight: { optimal: [6, 8], acceptable: [5, 9] }
},
expectedYield: {
high: [250, 350],
medium: [180, 250],
low: [120, 180]
},
riskFactors: [
{
id: 'soybean-disease',
name: '病害风险',
condition: '高温高湿环境',
severity: 'medium' as const,
suggestion: '选择抗病品种,合理轮作,加强田间管理'
}
]
}
];
interface KnowledgeBaseDialogProps {
showKnowledgeBase: boolean;
onClose: () => void;
}
export function KnowledgeBaseDialog({ showKnowledgeBase, onClose }: KnowledgeBaseDialogProps) {
return (
<Dialog open={showKnowledgeBase} onOpenChange={onClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-green-600" />
-
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 mt-4">
{/* 知识库统计 */}
<div className="grid grid-cols-4 gap-4">
<Card className="p-4 bg-green-50 dark:bg-green-950">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-green-700 dark:text-green-300 mb-1"></p>
<p className="text-2xl font-semibold text-green-900 dark:text-green-100">{cropKnowledgeBase.length}</p>
</div>
<Database className="w-8 h-8 text-green-600 dark:text-green-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-950">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-700 dark:text-blue-300 mb-1"></p>
<p className="text-2xl font-semibold text-blue-900 dark:text-blue-100">
{cropKnowledgeBase.filter(c => c.category === '粮食作物').length}
</p>
</div>
<Leaf className="w-8 h-8 text-blue-600 dark:text-blue-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-amber-50 dark:bg-amber-950">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-amber-700 dark:text-amber-300 mb-1"></p>
<p className="text-2xl font-semibold text-amber-900 dark:text-amber-100">
{cropKnowledgeBase.filter(c => c.category === '经济作物').length}
</p>
</div>
<Target className="w-8 h-8 text-amber-600 dark:text-amber-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-purple-50 dark:bg-purple-950">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-purple-700 dark:text-purple-300 mb-1"></p>
<p className="text-2xl font-semibold text-purple-900 dark:text-purple-100">
{cropKnowledgeBase.filter(c => c.category === '蔬菜作物').length}
</p>
</div>
<Droplet className="w-8 h-8 text-purple-600 dark:text-purple-400 opacity-50" />
</div>
</Card>
</div>
{/* 作物列表 */}
<div className="space-y-4">
{cropKnowledgeBase.map((crop) => (
<Card key={crop.id} className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h3 className="text-green-800 dark:text-green-200">{crop.cropName}</h3>
<Badge variant={
crop.category === '粮食作物' ? 'default' :
crop.category === '经济作物' ? 'secondary' :
'outline'
}>
{crop.category}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{crop.description}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-semibold text-green-700 dark:text-green-300">{crop.growthCycle.days}</p>
<p className="text-xs text-muted-foreground mt-1">
{crop.growthCycle.seasons.join('、')}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
{/* 土壤环境要求 */}
<div>
<h4 className="mb-3 flex items-center gap-2">
<Database className="w-4 h-4 text-green-600" />
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground">pH值:</span>
<span className="font-medium">
{crop.soilRequirements.ph.optimal[0]}-{crop.soilRequirements.ph.optimal[1]}
<span className="text-muted-foreground text-xs ml-2">
( {crop.soilRequirements.ph.acceptable[0]}-{crop.soilRequirements.ph.acceptable[1]})
</span>
</span>
</div>
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground">:</span>
<span className="font-medium">
{crop.soilRequirements.organicMatter.optimal[0]}-{crop.soilRequirements.organicMatter.optimal[1]} g/kg
</span>
</div>
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground">:</span>
<span className="font-medium">
{crop.soilRequirements.soilDepth.optimal[0]}-{crop.soilRequirements.soilDepth.optimal[1]} cm
</span>
</div>
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground">:</span>
<span className="font-medium">
{crop.soilRequirements.nitrogen.optimal[0]}-{crop.soilRequirements.nitrogen.optimal[1]} g/kg
</span>
</div>
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground">:</span>
<span className="font-medium">
{crop.soilRequirements.phosphorus.optimal[0]}-{crop.soilRequirements.phosphorus.optimal[1]} g/kg
</span>
</div>
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground">:</span>
<span className="font-medium">
{crop.soilRequirements.potassium.optimal[0]}-{crop.soilRequirements.potassium.optimal[1]} g/kg
</span>
</div>
<div className="flex justify-between py-1.5">
<span className="text-muted-foreground">:</span>
<span className="font-medium">
{crop.soilRequirements.drainage.optimal[0]}-{crop.soilRequirements.drainage.optimal[1]}
</span>
</div>
</div>
</div>
{/* 气候要求与产量 */}
<div>
<h4 className="mb-3 flex items-center gap-2">
<Cloud className="w-4 h-4 text-blue-600" />
</h4>
<div className="space-y-2 text-sm mb-4">
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground flex items-center gap-1">
<ThermometerSun className="w-3 h-3" />
:
</span>
<span className="font-medium">
{crop.climateRequirements.temperature.optimal[0]}-{crop.climateRequirements.temperature.optimal[1]} °C
</span>
</div>
<div className="flex justify-between py-1.5 border-b">
<span className="text-muted-foreground flex items-center gap-1">
<Droplet className="w-3 h-3" />
:
</span>
<span className="font-medium">
{crop.climateRequirements.rainfall.optimal[0]}-{crop.climateRequirements.rainfall.optimal[1]} mm/
</span>
</div>
<div className="flex justify-between py-1.5">
<span className="text-muted-foreground flex items-center gap-1">
<Sun className="w-3 h-3" />
:
</span>
<span className="font-medium">
{crop.climateRequirements.sunlight.optimal[0]}-{crop.climateRequirements.sunlight.optimal[1]} /
</span>
</div>
</div>
<h4 className="mb-3 flex items-center gap-2 mt-4">
<TrendingUp className="w-4 h-4 text-amber-600" />
(kg/)
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between py-1.5 border-b bg-green-50 dark:bg-green-950 px-2 rounded">
<span className="text-green-700 dark:text-green-300">:</span>
<span className="font-semibold text-green-800 dark:text-green-200">
{crop.expectedYield.high[0]}-{crop.expectedYield.high[1]}
</span>
</div>
<div className="flex justify-between py-1.5 border-b bg-blue-50 dark:bg-blue-950 px-2 rounded">
<span className="text-blue-700 dark:text-blue-300">:</span>
<span className="font-semibold text-blue-800 dark:text-blue-200">
{crop.expectedYield.medium[0]}-{crop.expectedYield.medium[1]}
</span>
</div>
<div className="flex justify-between py-1.5 bg-gray-50 dark:bg-gray-900 px-2 rounded">
<span className="text-gray-700 dark:text-gray-300">:</span>
<span className="font-semibold text-gray-800 dark:text-gray-200">
{crop.expectedYield.low[0]}-{crop.expectedYield.low[1]}
</span>
</div>
</div>
</div>
</div>
{/* 风险因子 */}
{crop.riskFactors.length > 0 && (
<div className="mt-4 pt-4 border-t">
<h4 className="mb-3 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600" />
</h4>
<div className="grid grid-cols-2 gap-3">
{crop.riskFactors.map((risk) => (
<div
key={risk.id}
className={`p-3 rounded-lg border ${
risk.severity === 'high' ? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800' :
risk.severity === 'medium' ? 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800' :
'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800'
}`}
>
<div className="flex items-start justify-between mb-2">
<p className={`text-sm font-medium ${
risk.severity === 'high' ? 'text-red-900 dark:text-red-100' :
risk.severity === 'medium' ? 'text-amber-900 dark:text-amber-100' :
'text-blue-900 dark:text-blue-100'
}`}>
{risk.name}
</p>
<Badge
variant={risk.severity === 'high' ? 'destructive' : 'outline'}
className="text-xs"
>
{risk.severity === 'high' ? '高风险' : risk.severity === 'medium' ? '中风险' : '低风险'}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
: {risk.condition}
</p>
<p className="text-xs text-foreground">
: {risk.suggestion}
</p>
</div>
))}
</div>
</div>
)}
</Card>
))}
</div>
</div>
<div className="flex justify-end pt-4 border-t mt-4">
<Button onClick={onClose}>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useReducer } from 'react';
import { toast } from 'sonner';
export interface EvaluationFactor {
id: string;
name: string;
value: number;
weight: number;
unit: string;
optimalRange: [number, number];
score: number;
}
export interface SuitabilityResult {
fieldId: string;
fieldName: string;
totalScore: number;
grade: '高度适宜' | '一般适宜' | '不适宜';
factors: EvaluationFactor[];
timestamp: string;
}
export interface CropRecommendationState {
evaluationResults: SuitabilityResult[];
selectedField: string;
showKnowledgeBase: boolean;
}
export type CropRecommendAction =
| { type: 'SET_SELECTED_FIELD'; payload: string }
| { type: 'SET_SHOW_KNOWLEDGE_BASE'; payload: boolean }
| { type: 'SET_EVALUATION_RESULTS'; payload: SuitabilityResult[] }
| { type: 'UPDATE_EVALUATION_RESULT'; payload: SuitabilityResult };
const initialState: CropRecommendationState = {
evaluationResults: [
{
fieldId: 'field-1',
fieldName: '东区1号地',
totalScore: 87,
grade: '高度适宜',
factors: [
{ id: 'ph', name: 'pH值', value: 6.5, weight: 20, unit: '', optimalRange: [6.0, 7.5], score: 95 },
{ id: 'organic', name: '有机质含量', value: 32, weight: 25, unit: 'g/kg', optimalRange: [25, 40], score: 90 },
{ id: 'depth', name: '土层厚度', value: 85, weight: 20, unit: 'cm', optimalRange: [60, 100], score: 88 },
{ id: 'nitrogen', name: '全氮', value: 1.8, weight: 10, unit: 'g/kg', optimalRange: [1.5, 2.5], score: 85 },
{ id: 'phosphorus', name: '全磷', value: 1.2, weight: 10, unit: 'g/kg', optimalRange: [1.0, 2.0], score: 80 },
{ id: 'potassium', name: '全钾', value: 18, weight: 10, unit: 'g/kg', optimalRange: [15, 25], score: 82 },
{ id: 'drainage', name: '排水性', value: 4, weight: 5, unit: '', optimalRange: [3, 5], score: 90 },
],
timestamp: '2024-10-15 14:30',
},
{
fieldId: 'field-2',
fieldName: '西区2号地',
totalScore: 72,
grade: '一般适宜',
factors: [
{ id: 'ph', name: 'pH值', value: 7.8, weight: 20, unit: '', optimalRange: [6.0, 7.5], score: 65 },
{ id: 'organic', name: '有机质含量', value: 22, weight: 25, unit: 'g/kg', optimalRange: [25, 40], score: 70 },
{ id: 'depth', name: '土层厚度', value: 55, weight: 20, unit: 'cm', optimalRange: [60, 100], score: 68 },
{ id: 'nitrogen', name: '全氮', value: 1.3, weight: 10, unit: 'g/kg', optimalRange: [1.5, 2.5], score: 72 },
{ id: 'phosphorus', name: '全磷', value: 0.9, weight: 10, unit: 'g/kg', optimalRange: [1.0, 2.0], score: 75 },
{ id: 'potassium', name: '全钾', value: 14, weight: 10, unit: 'g/kg', optimalRange: [15, 25], score: 78 },
{ id: 'drainage', name: '排水性', value: 3, weight: 5, unit: '', optimalRange: [3, 5], score: 85 },
],
timestamp: '2024-10-15 14:28',
},
{
fieldId: 'field-3',
fieldName: '南区3号地',
totalScore: 58,
grade: '不适宜',
factors: [
{ id: 'ph', name: 'pH值', value: 8.5, weight: 20, unit: '', optimalRange: [6.0, 7.5], score: 45 },
{ id: 'organic', name: '有机质含量', value: 15, weight: 25, unit: 'g/kg', optimalRange: [25, 40], score: 52 },
{ id: 'depth', name: '土层厚度', value: 42, weight: 20, unit: 'cm', optimalRange: [60, 100], score: 55 },
{ id: 'nitrogen', name: '全氮', value: 0.8, weight: 10, unit: 'g/kg', optimalRange: [1.5, 2.5], score: 60 },
{ id: 'phosphorus', name: '全磷', value: 0.6, weight: 10, unit: 'g/kg', optimalRange: [1.0, 2.0], score: 65 },
{ id: 'potassium', name: '全钾', value: 10, weight: 10, unit: 'g/kg', optimalRange: [15, 25], score: 58 },
{ id: 'drainage', name: '排水性', value: 2, weight: 5, unit: '', optimalRange: [3, 5], score: 70 },
],
timestamp: '2024-10-15 14:25',
},
],
selectedField: 'field-1',
showKnowledgeBase: false,
};
export function cropRecommendReducer(
state: CropRecommendationState = initialState,
action: CropRecommendAction
): CropRecommendationState {
switch (action.type) {
case 'SET_SELECTED_FIELD':
return {
...state,
selectedField: action.payload,
};
case 'SET_SHOW_KNOWLEDGE_BASE':
return {
...state,
showKnowledgeBase: action.payload,
};
case 'SET_EVALUATION_RESULTS':
return {
...state,
evaluationResults: action.payload,
};
case 'UPDATE_EVALUATION_RESULT':
return {
...state,
evaluationResults: state.evaluationResults.map(result =>
result.fieldId === action.payload.fieldId ? action.payload : result
),
};
default:
return state;
}
}
export { initialState };

View File

@@ -0,0 +1,188 @@
'use client';
import { useReducer } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { BookOpen, Target } from 'lucide-react';
import {
cropRecommendReducer,
initialState,
SuitabilityResult
} from './components/cropRecommendReducer';
import { FieldEnvironmentOverview } from './components/FieldEnvironmentOverview';
import { CropRecommendations } from './components/CropRecommendations';
import { KnowledgeBaseDialog } from './components/KnowledgeBaseDialog';
export default function CropPage() {
const [state, dispatch] = useReducer(cropRecommendReducer, initialState);
// 获取当前选中的地块结果
const currentResult = state.evaluationResults.find(r => r.fieldId === state.selectedField) || state.evaluationResults[0];
const handleFieldChange = (value: string) => {
dispatch({ type: 'SET_SELECTED_FIELD', payload: value });
};
const handleToggleKnowledgeBase = () => {
dispatch({ type: 'SET_SHOW_KNOWLEDGE_BASE', payload: !state.showKnowledgeBase });
};
const getGradeColor = (grade: string) => {
switch (grade) {
case '高度适宜': return 'bg-green-500';
case '一般适宜': return 'bg-yellow-500';
case '不适宜': return 'bg-red-500';
default: return 'bg-gray-500';
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleToggleKnowledgeBase}>
<BookOpen className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 地块选择和适宜性概览 */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div className="lg:col-span-1">
<div className="space-y-4">
<div className="p-4 bg-card rounded-lg border">
<label className="text-xs text-muted-foreground mb-2 block"></label>
<Select value={state.selectedField} onValueChange={handleFieldChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{state.evaluationResults.map((result) => (
<SelectItem key={result.fieldId} value={result.fieldId}>
{result.fieldName} - {result.grade} ({result.totalScore})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 适宜性评分卡片 */}
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-center">
<Target className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-3" />
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="text-4xl font-bold text-green-600 dark:text-green-400 mb-2">{currentResult.totalScore}</p>
<Badge className={`${getGradeColor(currentResult.grade)} text-white`}>
{currentResult.grade}
</Badge>
</div>
</div>
{/* 因子评分统计 */}
<div className="p-4 bg-card rounded-lg border">
<p className="text-sm font-medium mb-3"></p>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground"></span>
<span className="text-green-600 font-medium">
{currentResult.factors.filter(f => f.score >= 80).length}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground"></span>
<span className="text-red-600 font-medium">
{currentResult.factors.filter(f => f.score < 70).length}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{currentResult.timestamp}</span>
</div>
</div>
</div>
</div>
</div>
<div className="lg:col-span-3">
<FieldEnvironmentOverview currentResult={currentResult} />
</div>
</div>
{/* 智能作物推荐 */}
<CropRecommendations state={state} currentResult={currentResult} />
{/* 知识库对话框 */}
<KnowledgeBaseDialog
showKnowledgeBase={state.showKnowledgeBase}
onClose={handleToggleKnowledgeBase}
/>
{/* 系统说明 */}
<div className="p-5 bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<Target className="w-6 h-6 text-green-700 dark:text-green-300 flex-shrink-0" />
</div>
<div className="flex-1">
<h4 className="text-green-900 dark:text-green-100 mb-3">-</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm text-green-800 dark:text-green-200">
<div>
<p className="font-medium mb-2">🌾 </p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: </li>
<li> <strong></strong>: pH7</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
</ul>
</div>
<div>
<p className="font-medium mb-2">🎯 </p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: </li>
<li> <strong></strong>: 10060</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: 8570-84</li>
</ul>
</div>
<div>
<p className="font-medium mb-2">📊 </p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: //</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
</ul>
</div>
<div>
<p className="font-medium mb-2"> </p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: //</li>
<li> <strong></strong>: </li>
</ul>
</div>
</div>
<div className="mt-4 p-3 bg-white/60 dark:bg-black/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs text-green-900 dark:text-green-100">
<strong>💡 </strong>
-
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,18 +0,0 @@
'use client';
import { Card } from '@/components/ui/card';
export default function WeightPage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/suitability/weight
</p>
</div>
</Card>
</div>
);
}

View File

@@ -318,17 +318,17 @@ const fieldMessageManagement = {
items: [
{
title: "多因子综合评价",
url: "/land-information/suitability/comprehensive",
url: "/land-information/suitability/multiFactor",
isActive: false
},
{
title: "自动化空间分析",
url: "/land-information/suitability/batch",
url: "/land-information/suitability/auto",
isActive: false
},
{
title: "作物适配推荐",
url: "/land-information/suitability/crop",
url: "/land-information/suitability/recommend",
isActive: false
}
]