生产管理系统前端 - 地块管理三个页面开发:1.地块影像,土壤基础数据,分层采样分析

This commit is contained in:
2025-10-30 10:28:44 +08:00
parent 71bc00cc4e
commit 304edcbb38
25 changed files with 4602 additions and 31 deletions

View File

@@ -0,0 +1,141 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { Box, Layers, Grid3x3, ZoomIn, ZoomOut } from 'lucide-react';
interface ControlPanelProps {
selectedField: string;
selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium';
viewMode: '3d' | 'slice' | 'contour';
depthSlice: number[];
zoomLevel: number;
onFieldChange: (value: string) => void;
onNutrientChange: (value: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium') => void;
onViewModeChange: (value: '3d' | 'slice' | 'contour') => void;
onDepthSliceChange: (value: number[]) => void;
onZoomIn: () => void;
onZoomOut: () => void;
}
export function ControlPanel({
selectedField,
selectedNutrient,
viewMode,
depthSlice,
zoomLevel,
onFieldChange,
onNutrientChange,
onViewModeChange,
onDepthSliceChange,
onZoomIn,
onZoomOut,
}: ControlPanelProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
<Label className="mb-2 text-sm"></Label>
<Select value={selectedField} onValueChange={onFieldChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="field-1">1</SelectItem>
<SelectItem value="field-2">西2</SelectItem>
<SelectItem value="field-3">3</SelectItem>
</SelectContent>
</Select>
</Card>
<Card className="p-4">
<Label className="mb-2 text-sm"></Label>
<Select value={selectedNutrient} onValueChange={onNutrientChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="organic"></SelectItem>
<SelectItem value="nitrogen"></SelectItem>
<SelectItem value="phosphorus"></SelectItem>
<SelectItem value="potassium"></SelectItem>
</SelectContent>
</Select>
</Card>
<Card className="p-4">
<Label className="mb-2 text-sm"></Label>
<div className="flex gap-1">
<Button
variant={viewMode === '3d' ? 'default' : 'outline'}
size="sm"
onClick={() => onViewModeChange('3d')}
className="flex-1"
>
<Box className="w-3 h-3 mr-1" />
3D
</Button>
<Button
variant={viewMode === 'slice' ? 'default' : 'outline'}
size="sm"
onClick={() => onViewModeChange('slice')}
className="flex-1"
>
<Layers className="w-3 h-3 mr-1" />
</Button>
<Button
variant={viewMode === 'contour' ? 'default' : 'outline'}
size="sm"
onClick={() => onViewModeChange('contour')}
className="flex-1"
>
<Grid3x3 className="w-3 h-3 mr-1" />
</Button>
</div>
</Card>
<Card className="p-4">
<Label className="mb-2 text-sm"></Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onZoomOut}
>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-sm flex-1 text-center">{zoomLevel}%</span>
<Button
variant="outline"
size="sm"
onClick={onZoomIn}
>
<ZoomIn className="w-4 h-4" />
</Button>
</div>
</Card>
{viewMode === 'slice' && (
<Card className="p-4 lg:col-span-4">
<Label className="mb-2 text-sm"></Label>
<Slider
value={depthSlice}
onValueChange={onDepthSliceChange}
max={60}
step={10}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>0cm</span>
<span className="font-medium">{depthSlice[0]}cm</span>
<span>60cm</span>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Activity, TrendingUp } from 'lucide-react';
interface DataAnalysisProps {
selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium';
}
export function DataAnalysis({ selectedNutrient }: DataAnalysisProps) {
const nutrientConfig = {
organic: { label: '有机质', unit: 'g/kg', color: '#22c55e', min: 0, max: 40 },
nitrogen: { label: '全氮', unit: 'g/kg', color: '#3b82f6', min: 0, max: 2 },
phosphorus: { label: '有效磷', unit: 'mg/kg', color: '#f59e0b', min: 0, max: 40 },
potassium: { label: '速效钾', unit: 'mg/kg', color: '#a855f7', min: 0, max: 250 },
};
const currentNutrient = nutrientConfig[selectedNutrient];
const layers3DData = [
{ depth: '0-20cm', avgValue: 28.5, distribution: [25, 28, 32, 27, 30, 26, 29, 31, 28, 27] },
{ depth: '20-40cm', avgValue: 20.2, distribution: [18, 21, 23, 19, 22, 20, 21, 22, 19, 20] },
{ depth: '40-60cm', avgValue: 13.5, distribution: [12, 14, 15, 13, 14, 12, 13, 15, 14, 13] },
];
const getColorForValue = (value: number, nutrient: typeof selectedNutrient) => {
const config = nutrientConfig[nutrient];
const ratio = (value - config.min) / (config.max - config.min);
if (ratio < 0.2) return '#ef4444';
if (ratio < 0.4) return '#f97316';
if (ratio < 0.6) return '#eab308';
if (ratio < 0.8) return '#84cc16';
return '#22c55e';
};
return (
<div className="space-y-4">
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<Activity className="w-5 h-5 text-blue-600" />
<h4></h4>
</div>
<div className="space-y-3">
{layers3DData.map((layer, index) => (
<div key={index}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">{layer.depth}</span>
<span className="font-medium">
{layer.avgValue} {currentNutrient.unit}
</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full transition-all"
style={{
width: `${(layer.avgValue / currentNutrient.max) * 100}%`,
backgroundColor: getColorForValue(layer.avgValue, selectedNutrient),
}}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t">
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-red-600">-29.1%/20cm</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-green-600"></span>
</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-5 h-5 text-orange-600" />
<h4></h4>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>14.8%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<Badge className="bg-yellow-500"></Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-green-600">32.1 {currentNutrient.unit}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-orange-600">22.3 {currentNutrient.unit}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>9.8 {currentNutrient.unit}</span>
</div>
</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<h4 className="mb-2 text-blue-900 dark:text-blue-100"></h4>
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<p> <strong></strong>Kriging</p>
<p> </p>
<p> </p>
<p> </p>
</div>
</Card>
<Card className="p-4 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
<h4 className="mb-2 text-green-900 dark:text-green-100"></h4>
<ul className="text-xs text-green-800 dark:text-green-200 space-y-1">
<li> </li>
<li> 0-20cm</li>
<li> </li>
<li> </li>
</ul>
</Card>
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { Card } from '@/components/ui/card';
export function DataTable() {
return (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm"></th>
<th className="px-4 py-3 text-center text-sm"> (g/kg)</th>
<th className="px-4 py-3 text-center text-sm"> (g/kg)</th>
<th className="px-4 py-3 text-center text-sm"> (mg/kg)</th>
<th className="px-4 py-3 text-center text-sm"> (mg/kg)</th>
<th className="px-4 py-3 text-center text-sm">pH值</th>
<th className="px-4 py-3 text-center text-sm"> (%)</th>
</tr>
</thead>
<tbody>
<tr className="border-t">
<td className="px-4 py-3 font-medium">0-20cm</td>
<td className="px-4 py-3 text-center">28.5</td>
<td className="px-4 py-3 text-center">1.2</td>
<td className="px-4 py-3 text-center">25.3</td>
<td className="px-4 py-3 text-center">180</td>
<td className="px-4 py-3 text-center">6.8</td>
<td className="px-4 py-3 text-center">22.5</td>
</tr>
<tr className="border-t bg-muted/50">
<td className="px-4 py-3 font-medium">20-40cm</td>
<td className="px-4 py-3 text-center">20.2</td>
<td className="px-4 py-3 text-center">0.8</td>
<td className="px-4 py-3 text-center">18.5</td>
<td className="px-4 py-3 text-center">145</td>
<td className="px-4 py-3 text-center">6.5</td>
<td className="px-4 py-3 text-center">25.8</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 font-medium">40-60cm</td>
<td className="px-4 py-3 text-center">13.5</td>
<td className="px-4 py-3 text-center">0.5</td>
<td className="px-4 py-3 text-center">12.3</td>
<td className="px-4 py-3 text-center">98</td>
<td className="px-4 py-3 text-center">6.3</td>
<td className="px-4 py-3 text-center">28.2</td>
</tr>
<tr className="border-t bg-yellow-50 dark:bg-yellow-950">
<td className="px-4 py-3 font-medium"></td>
<td className="px-4 py-3 text-center text-red-600">-29.1%</td>
<td className="px-4 py-3 text-center text-red-600">-33.3%</td>
<td className="px-4 py-3 text-center text-red-600">-26.8%</td>
<td className="px-4 py-3 text-center text-red-600">-19.4%</td>
<td className="px-4 py-3 text-center text-orange-600">-7.4%</td>
<td className="px-4 py-3 text-center text-blue-600">+25.3%</td>
</tr>
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { Card } from '@/components/ui/card';
import { AlertCircle } from 'lucide-react';
export function UsageGuide() {
return (
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<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>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: 线</li>
<li> <strong></strong>: 使</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
</ul>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Box, Layers, Grid3x3, Eye, RotateCw, ZoomIn, ZoomOut } from 'lucide-react';
interface Visualization3DProps {
viewMode: '3d' | 'slice' | 'contour';
selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium';
depthSlice: number[];
rotationAngle: number;
zoomLevel: number;
onRotate: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
}
export function Visualization3D({
viewMode,
selectedNutrient,
depthSlice,
rotationAngle,
zoomLevel,
onRotate,
onZoomIn,
onZoomOut,
}: Visualization3DProps) {
const nutrientConfig = {
organic: { label: '有机质', unit: 'g/kg', color: '#22c55e', min: 0, max: 40 },
nitrogen: { label: '全氮', unit: 'g/kg', color: '#3b82f6', min: 0, max: 2 },
phosphorus: { label: '有效磷', unit: 'mg/kg', color: '#f59e0b', min: 0, max: 40 },
potassium: { label: '速效钾', unit: 'mg/kg', color: '#a855f7', min: 0, max: 250 },
};
const currentNutrient = nutrientConfig[selectedNutrient];
const layers3DData = [
{ depth: '0-20cm', avgValue: 28.5, distribution: [25, 28, 32, 27, 30, 26, 29, 31, 28, 27] },
{ depth: '20-40cm', avgValue: 20.2, distribution: [18, 21, 23, 19, 22, 20, 21, 22, 19, 20] },
{ depth: '40-60cm', avgValue: 13.5, distribution: [12, 14, 15, 13, 14, 12, 13, 15, 14, 13] },
];
const getColorForValue = (value: number, nutrient: typeof selectedNutrient) => {
const config = nutrientConfig[nutrient];
const ratio = (value - config.min) / (config.max - config.min);
if (ratio < 0.2) return '#ef4444';
if (ratio < 0.4) return '#f97316';
if (ratio < 0.6) return '#eab308';
if (ratio < 0.8) return '#84cc16';
return '#22c55e';
};
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Box className="w-5 h-5 text-green-600" />
<h3>
{viewMode === '3d' && '三维分布模型'}
{viewMode === 'slice' && '立体切片图'}
{viewMode === 'contour' && '等值面图'}
</h3>
</div>
<Badge>{currentNutrient.label}</Badge>
</div>
<div
className="relative bg-gradient-to-br from-gray-100 to-gray-50 dark:from-gray-800 dark:to-gray-900 rounded-lg border-2 border-gray-200 dark:border-gray-700 overflow-hidden"
style={{ height: '500px' }}
>
{viewMode === '3d' && (
<div className="absolute inset-0 flex items-center justify-center p-8">
<div
className="relative w-full h-full"
style={{
transform: `perspective(1000px) rotateX(60deg) rotateZ(${rotationAngle}deg) scale(${zoomLevel / 100})`,
transformStyle: 'preserve-3d',
transition: 'transform 0.5s ease',
}}
>
{layers3DData.map((layer, layerIndex) => {
const offsetY = layerIndex * 60;
return (
<div
key={layerIndex}
className="absolute"
style={{
width: '300px',
height: '200px',
left: '50%',
top: '50%',
marginLeft: '-150px',
marginTop: '-100px',
transform: `translateZ(${-offsetY}px)`,
transformStyle: 'preserve-3d',
}}
>
<div className="grid grid-cols-5 grid-rows-2 w-full h-full gap-1">
{layer.distribution.map((value, cellIndex) => (
<div
key={cellIndex}
className="rounded shadow-lg border border-white/30"
style={{
backgroundColor: getColorForValue(value, selectedNutrient),
opacity: 0.85,
}}
title={`${value} ${currentNutrient.unit}`}
/>
))}
</div>
<div className="absolute -left-20 top-1/2 transform -translate-y-1/2 bg-card px-3 py-1 rounded shadow text-sm font-medium whitespace-nowrap border">
{layer.depth}
</div>
</div>
);
})}
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 text-xs text-muted-foreground">
</div>
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 -rotate-90 text-xs text-muted-foreground">
</div>
</div>
</div>
)}
{viewMode === 'slice' && (
<div className="p-8">
<div className="mb-4">
<div className="text-sm font-medium mb-2"></div>
<div className="text-center text-lg font-semibold text-muted-foreground mb-4">
{depthSlice[0]}cm
</div>
<div className="grid grid-cols-5 grid-rows-5 gap-2 w-full aspect-square">
{Array.from({ length: 25 }).map((_, index) => {
const layerIndex = Math.floor(depthSlice[0] / 20);
const baseValue = layers3DData[layerIndex]?.avgValue || 15;
const variation = (Math.sin(index * 0.5) * 5);
const value = baseValue + variation;
return (
<div
key={index}
className="rounded-lg shadow-md border-2 border-white dark:border-gray-800 flex items-center justify-center text-xs"
style={{
backgroundColor: getColorForValue(value, selectedNutrient),
}}
>
<span className="text-white font-medium drop-shadow">
{value.toFixed(1)}
</span>
</div>
);
})}
</div>
<div className="mt-4 text-center text-sm text-muted-foreground">
{depthSlice[0]}cm
</div>
</div>
</div>
)}
{viewMode === 'contour' && (
<div className="p-8">
<div className="relative w-full h-full">
<svg className="w-full h-full">
{[30, 25, 20, 15, 10].map((value, index) => {
const radius = 50 + index * 40;
const color = getColorForValue(value, selectedNutrient);
return (
<g key={value}>
<ellipse
cx="50%"
cy="50%"
rx={`${radius}%`}
ry={`${radius * 0.7}%`}
fill={color}
fillOpacity="0.3"
stroke={color}
strokeWidth="2"
/>
<text
x={`${50 + radius * 0.7}%`}
y="50%"
className="text-xs"
fill={color}
>
{value} {currentNutrient.unit}
</text>
</g>
);
})}
</svg>
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-sm text-muted-foreground">
0-20cm线
</div>
</div>
</div>
)}
<div className="absolute top-4 right-4 bg-card/90 backdrop-blur px-3 py-2 rounded-lg shadow text-xs border">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 text-green-600" />
<span>: {rotationAngle}°</span>
</div>
</div>
<div className="absolute bottom-4 right-4 flex gap-2">
<button
onClick={onRotate}
className="bg-card hover:bg-muted p-2 rounded-lg shadow border transition-colors"
title="旋转视角"
>
<RotateCw className="w-4 h-4" />
</button>
<button
onClick={onZoomOut}
className="bg-card hover:bg-muted p-2 rounded-lg shadow border transition-colors"
title="缩小"
>
<ZoomOut className="w-4 h-4" />
</button>
<button
onClick={onZoomIn}
className="bg-card hover:bg-muted p-2 rounded-lg shadow border transition-colors"
title="放大"
>
<ZoomIn className="w-4 h-4" />
</button>
</div>
</div>
<div className="mt-4 p-4 bg-muted rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium"></span>
<span className="text-xs text-muted-foreground">: {currentNutrient.unit}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs">{currentNutrient.min}</span>
<div className="flex-1 h-6 rounded-full" style={{
background: 'linear-gradient(to right, #ef4444, #f97316, #eab308, #84cc16, #22c55e)',
}} />
<span className="text-xs">{currentNutrient.max}</span>
</div>
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { ReactNode } from 'react';
export interface LayerSamplingState {
selectedField: string;
selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium';
viewMode: '3d' | 'slice' | 'contour';
depthSlice: number[];
rotationAngle: number;
zoomLevel: number;
}
export type LayerSamplingAction =
| { type: 'SET_SELECTED_FIELD'; payload: string }
| { type: 'SET_SELECTED_NUTRIENT'; payload: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium' }
| { type: 'SET_VIEW_MODE'; payload: '3d' | 'slice' | 'contour' }
| { type: 'SET_DEPTH_SLICE'; payload: number[] }
| { type: 'SET_ROTATION_ANGLE'; payload: number }
| { type: 'SET_ZOOM_LEVEL'; payload: number }
| { type: 'ROTATE' }
| { type: 'ZOOM_IN' }
| { type: 'ZOOM_OUT' };
export const initialState: LayerSamplingState = {
selectedField: 'field-1',
selectedNutrient: 'organic',
viewMode: '3d',
depthSlice: [20],
rotationAngle: 45,
zoomLevel: 100,
};
export function layerSamplingReducer(state: LayerSamplingState, action: LayerSamplingAction): LayerSamplingState {
switch (action.type) {
case 'SET_SELECTED_FIELD':
return { ...state, selectedField: action.payload };
case 'SET_SELECTED_NUTRIENT':
return { ...state, selectedNutrient: action.payload };
case 'SET_VIEW_MODE':
return { ...state, viewMode: action.payload };
case 'SET_DEPTH_SLICE':
return { ...state, depthSlice: action.payload };
case 'SET_ROTATION_ANGLE':
return { ...state, rotationAngle: action.payload };
case 'SET_ZOOM_LEVEL':
return { ...state, zoomLevel: action.payload };
case 'ROTATE':
return { ...state, rotationAngle: (state.rotationAngle + 45) % 360 };
case 'ZOOM_IN':
return { ...state, zoomLevel: Math.min(200, state.zoomLevel + 10) };
case 'ZOOM_OUT':
return { ...state, zoomLevel: Math.max(50, state.zoomLevel - 10) };
default:
return state;
}
}

View File

@@ -1,18 +1,107 @@
'use client';
import { useReducer } from 'react';
import { toast } from 'sonner';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { RotateCw } from 'lucide-react';
import {
layerSamplingReducer,
initialState,
LayerSamplingState,
LayerSamplingAction
} from './components/layerSamplingReducer';
import { ControlPanel } from './components/ControlPanel';
import { Visualization3D } from './components/Visualization3D';
import { DataAnalysis } from './components/DataAnalysis';
import { DataTable } from './components/DataTable';
import { UsageGuide } from './components/UsageGuide';
export default function LayerSamplingPage() {
const [state, dispatch] = useReducer(layerSamplingReducer, initialState);
const handleFieldChange = (value: string) => {
dispatch({ type: 'SET_SELECTED_FIELD', payload: value });
};
const handleNutrientChange = (value: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium') => {
dispatch({ type: 'SET_SELECTED_NUTRIENT', payload: value });
};
const handleViewModeChange = (value: '3d' | 'slice' | 'contour') => {
dispatch({ type: 'SET_VIEW_MODE', payload: value });
};
const handleDepthSliceChange = (value: number[]) => {
dispatch({ type: 'SET_DEPTH_SLICE', payload: value });
};
const handleRotate = () => {
dispatch({ type: 'ROTATE' });
toast.success(`旋转至 ${state.rotationAngle}°`);
};
const handleZoomIn = () => {
dispatch({ type: 'ZOOM_IN' });
};
const handleZoomOut = () => {
dispatch({ type: 'ZOOM_OUT' });
};
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/analysis/layer-sampling
<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={handleRotate}>
<RotateCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<ControlPanel
selectedField={state.selectedField}
selectedNutrient={state.selectedNutrient}
viewMode={state.viewMode}
depthSlice={state.depthSlice}
zoomLevel={state.zoomLevel}
onFieldChange={handleFieldChange}
onNutrientChange={handleNutrientChange}
onViewModeChange={handleViewModeChange}
onDepthSliceChange={handleDepthSliceChange}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
/>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2">
<Visualization3D
viewMode={state.viewMode}
selectedNutrient={state.selectedNutrient}
depthSlice={state.depthSlice}
rotationAngle={state.rotationAngle}
zoomLevel={state.zoomLevel}
onRotate={handleRotate}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
/>
</div>
<div>
<DataAnalysis selectedNutrient={state.selectedNutrient} />
</div>
</div>
<DataTable />
<UsageGuide />
</div>
);
}

View File

@@ -0,0 +1,338 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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, Save, Droplets, Leaf, Zap, TrendingUp } from 'lucide-react';
import { SoilDataState, SoilDataAction } from './soilDataReducer';
interface AddSamplePointDialogProps {
state: SoilDataState;
dispatch: React.Dispatch<SoilDataAction>;
}
export default function AddSamplePointDialog({ state, dispatch }: AddSamplePointDialogProps) {
const handleAddSamplePoint = () => {
const newPoint = state.newPoint;
if (!newPoint.code || !newPoint.fieldName || !newPoint.sampleDate || !newPoint.sampler) {
return;
}
if (!newPoint.latitude || !newPoint.longitude || newPoint.latitude === 0 || newPoint.longitude === 0) {
return;
}
// 从设备读取数据并填充到分层信息
const layersWithData = (newPoint.layers || []).map(layer => {
if (layer.deviceId) {
const device = state.iotDevices.find(d => d.id === layer.deviceId);
if (device) {
return {
...layer,
pH: device.data.pH,
organicMatter: device.data.organicMatter,
nitrogen: device.data.nitrogen,
phosphorus: device.data.phosphorus,
potassium: device.data.potassium,
moisture: device.data.moisture,
};
}
}
return layer;
});
const samplePoint = {
id: `sp-${Date.now()}`,
...newPoint,
layers: layersWithData,
} as any;
dispatch({ type: 'ADD_SAMPLE_POINT', payload: samplePoint });
dispatch({ type: 'SET_SHOW_ADD_DIALOG', payload: false });
dispatch({ type: 'RESET_NEW_POINT' });
};
const handleMapPointSelect = (lat: number, lng: number) => {
dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: lat, longitude: lng } });
};
const handleAddLayer = () => {
const currentLayers = state.newPoint.layers || [];
const newLayer = {
depth: `${currentLayers.length * 20}-${(currentLayers.length + 1) * 20}cm`,
deviceId: '',
};
dispatch({
type: 'UPDATE_NEW_POINT',
payload: {
layers: [...currentLayers, newLayer]
}
});
};
const handleUpdateLayerDevice = (layerIndex: number, deviceId: string) => {
const updatedLayers = [...(state.newPoint.layers || [])];
updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], deviceId };
dispatch({
type: 'UPDATE_NEW_POINT',
payload: { layers: updatedLayers }
});
};
const handleRemoveLayer = (index: number) => {
const currentLayers = state.newPoint.layers || [];
if (currentLayers.length > 1) {
const updatedLayers = currentLayers.filter((_, i) => i !== index);
dispatch({
type: 'UPDATE_NEW_POINT',
payload: { layers: updatedLayers }
});
}
};
return (
<Dialog open={state.showAddDialog} onOpenChange={(open) => {
if (!open) {
dispatch({ type: 'SET_SHOW_ADD_DIALOG', payload: false });
dispatch({ type: 'RESET_NEW_POINT' });
}
}}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="sr-only">
GPS坐标定位和分层数据录入
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 基本信息 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={state.newPoint.code || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { code: e.target.value } })}
placeholder="如: SP004"
/>
</div>
<div>
<Label> *</Label>
<Input
value={state.newPoint.fieldName || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { fieldName: e.target.value } })}
placeholder="如: 东区1号地"
/>
</div>
<div>
<Label> *</Label>
<Input
type="date"
value={state.newPoint.sampleDate || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampleDate: e.target.value } })}
/>
</div>
<div>
<Label> *</Label>
<Input
value={state.newPoint.sampler || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampler: e.target.value } })}
placeholder="如: 张三"
/>
</div>
<div className="md:col-span-2">
<Label>IoT设备</Label>
<Select
value={state.newPoint.deviceId || 'none'}
onValueChange={(value) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { deviceId: value === 'none' ? '' : value } })}
>
<SelectTrigger>
<SelectValue placeholder="选择物联网设备" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{state.iotDevices.map((device) => (
<SelectItem key={device.id} value={device.id}>
<div className="flex items-center gap-2">
<span>{device.code} - {device.name}</span>
<Badge variant={device.status === 'online' ? 'default' : 'secondary'} className="ml-2">
{device.status === 'online' ? '在线' : '离线'}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{state.newPoint.deviceId && (() => {
const device = state.iotDevices.find(d => d.id === state.newPoint.deviceId);
return device ? (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="text-xs text-green-800 dark:text-green-200 mb-1">{device.data.lastUpdate}</div>
<div className="grid grid-cols-2 md:grid-cols-6 gap-2 text-xs text-green-700 dark:text-green-300">
<div>pH: {device.data.pH}</div>
<div>: {device.data.organicMatter}</div>
<div>: {device.data.nitrogen}</div>
<div>: {device.data.phosphorus}</div>
<div>: {device.data.potassium}</div>
<div>: {device.data.moisture}%</div>
</div>
</div>
) : null;
})()}
</div>
</div>
{/* GPS坐标定位 */}
<div>
<Label>GPS坐标定位 *</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div>
<Input
type="number"
value={state.newPoint.latitude || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: parseFloat(e.target.value) || 0 } })}
placeholder="纬度"
step="0.000001"
/>
</div>
<div>
<Input
type="number"
value={state.newPoint.longitude || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { longitude: parseFloat(e.target.value) || 0 } })}
placeholder="经度"
step="0.000001"
/>
</div>
</div>
<Card className="p-4 bg-muted">
<div className="text-center text-muted-foreground">
<p className="text-sm"></p>
<p className="text-xs mt-1">使</p>
</div>
</Card>
</div>
{/* 分层数据 */}
<div>
<div className="flex items-center justify-between mb-3">
<Label></Label>
<Button type="button" size="sm" variant="outline" onClick={handleAddLayer}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{(state.newPoint.layers || []).map((layer, layerIndex) => (
<Card key={layerIndex} className="p-4">
<div className="flex items-center justify-between mb-3">
<h4> {layerIndex + 1} ({layer.depth})</h4>
{(state.newPoint.layers || []).length > 1 && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRemoveLayer(layerIndex)}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-xs"> *</Label>
<Input
value={layer.depth}
onChange={(e) => {
const updatedLayers = [...(state.newPoint.layers || [])];
updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], depth: e.target.value };
dispatch({ type: 'UPDATE_NEW_POINT', payload: { layers: updatedLayers } });
}}
placeholder="如: 0-20cm"
/>
</div>
<div>
<Label className="text-xs">IoT设备</Label>
<Select
value={layer.deviceId || 'none'}
onValueChange={(value) => handleUpdateLayerDevice(layerIndex, value === 'none' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="选择设备读取数据" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{state.iotDevices.map((device) => (
<SelectItem key={device.id} value={device.id}>
<span>{device.code} - {device.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{layer.deviceId && (() => {
const device = state.iotDevices.find(d => d.id === layer.deviceId);
return device ? (
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-xs text-blue-800 dark:text-blue-200 mb-2">{device.data.lastUpdate}</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
<div className="flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-600" />
<span>pH: <strong>{device.data.pH}</strong></span>
</div>
<div className="flex items-center gap-2">
<Leaf className="w-4 h-4 text-green-600" />
<span>: <strong>{device.data.organicMatter}</strong> g/kg</span>
</div>
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-blue-600" />
<span>: <strong>{device.data.nitrogen}</strong> g/kg</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-orange-600" />
<span>: <strong>{device.data.phosphorus}</strong> mg/kg</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-600" />
<span>: <strong>{device.data.potassium}</strong> mg/kg</span>
</div>
<div className="flex items-center gap-2">
<Droplets className="w-4 h-4 text-cyan-600" />
<span>: <strong>{device.data.moisture}</strong>%</span>
</div>
</div>
</div>
) : null;
})()}
</Card>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => {
dispatch({ type: 'SET_SHOW_ADD_DIALOG', payload: false });
dispatch({ type: 'RESET_NEW_POINT' });
}}>
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleAddSamplePoint}
disabled={!state.newPoint.code || !state.newPoint.fieldName || !state.newPoint.sampleDate || !state.newPoint.sampler || !state.newPoint.latitude || !state.newPoint.longitude}
>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { SoilDataState, SoilDataAction } from './soilDataReducer';
interface DeleteConfirmDialogProps {
state: SoilDataState;
dispatch: React.Dispatch<SoilDataAction>;
}
export default function DeleteConfirmDialog({ state, dispatch }: DeleteConfirmDialogProps) {
const handleConfirmDelete = () => {
if (state.pointToDelete) {
dispatch({ type: 'DELETE_SAMPLE_POINT', payload: state.pointToDelete });
dispatch({ type: 'SET_POINT_TO_DELETE', payload: null });
dispatch({ type: 'SET_SHOW_DELETE_DIALOG', payload: false });
}
};
const handleCancel = () => {
dispatch({ type: 'SET_POINT_TO_DELETE', payload: null });
dispatch({ type: 'SET_SHOW_DELETE_DIALOG', payload: false });
};
const pointToDelete = state.samplePoints.find(p => p.id === state.pointToDelete);
return (
<AlertDialog open={state.showDeleteDialog} onOpenChange={(open) => {
if (!open) {
handleCancel();
}
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{pointToDelete?.code}
{pointToDelete && (
<span className="block mt-2 text-muted-foreground">
{pointToDelete.fieldName} | {pointToDelete.sampleDate}
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,344 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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, Save, Droplets, Leaf, Zap, TrendingUp } from 'lucide-react';
import { SoilDataState, SoilDataAction } from './soilDataReducer';
interface EditSamplePointDialogProps {
state: SoilDataState;
dispatch: React.Dispatch<SoilDataAction>;
}
export default function EditSamplePointDialog({ state, dispatch }: EditSamplePointDialogProps) {
const handleUpdateSamplePoint = () => {
const newPoint = state.newPoint;
if (!newPoint.code || !newPoint.fieldName || !newPoint.sampleDate || !newPoint.sampler) {
return;
}
if (!newPoint.latitude || !newPoint.longitude || newPoint.latitude === 0 || newPoint.longitude === 0) {
return;
}
if (!state.selectedPoint) return;
// 从设备读取数据并填充到分层信息
const layersWithData = (newPoint.layers || []).map(layer => {
if (layer.deviceId) {
const device = state.iotDevices.find(d => d.id === layer.deviceId);
if (device) {
return {
...layer,
pH: device.data.pH,
organicMatter: device.data.organicMatter,
nitrogen: device.data.nitrogen,
phosphorus: device.data.phosphorus,
potassium: device.data.potassium,
moisture: device.data.moisture,
};
}
}
return layer;
});
const updatedPoint = {
...state.selectedPoint,
...newPoint,
layers: layersWithData,
};
dispatch({ type: 'UPDATE_SAMPLE_POINT', payload: updatedPoint });
dispatch({ type: 'SET_SHOW_EDIT_DIALOG', payload: false });
dispatch({ type: 'SET_SELECTED_POINT', payload: null });
dispatch({ type: 'RESET_NEW_POINT' });
};
const handleMapPointSelect = (lat: number, lng: number) => {
dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: lat, longitude: lng } });
};
const handleAddLayer = () => {
const currentLayers = state.newPoint.layers || [];
const newLayer = {
depth: `${currentLayers.length * 20}-${(currentLayers.length + 1) * 20}cm`,
deviceId: '',
};
dispatch({
type: 'UPDATE_NEW_POINT',
payload: {
layers: [...currentLayers, newLayer]
}
});
};
const handleUpdateLayerDevice = (layerIndex: number, deviceId: string) => {
const updatedLayers = [...(state.newPoint.layers || [])];
updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], deviceId };
dispatch({
type: 'UPDATE_NEW_POINT',
payload: { layers: updatedLayers }
});
};
const handleRemoveLayer = (index: number) => {
const currentLayers = state.newPoint.layers || [];
if (currentLayers.length > 1) {
const updatedLayers = currentLayers.filter((_, i) => i !== index);
dispatch({
type: 'UPDATE_NEW_POINT',
payload: { layers: updatedLayers }
});
}
};
return (
<Dialog open={state.showEditDialog} onOpenChange={(open) => {
if (!open) {
dispatch({ type: 'SET_SHOW_EDIT_DIALOG', payload: false });
dispatch({ type: 'SET_SELECTED_POINT', payload: null });
dispatch({ type: 'RESET_NEW_POINT' });
}
}}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="sr-only">
GPS坐标定位和分层数据修改
</DialogDescription>
</DialogHeader>
{state.selectedPoint && (
<div className="space-y-6">
{/* 基本信息 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={state.newPoint.code || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { code: e.target.value } })}
placeholder="如: SP004"
/>
</div>
<div>
<Label> *</Label>
<Input
value={state.newPoint.fieldName || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { fieldName: e.target.value } })}
placeholder="如: 东区1号地"
/>
</div>
<div>
<Label> *</Label>
<Input
type="date"
value={state.newPoint.sampleDate || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampleDate: e.target.value } })}
/>
</div>
<div>
<Label> *</Label>
<Input
value={state.newPoint.sampler || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampler: e.target.value } })}
placeholder="如: 张三"
/>
</div>
<div className="md:col-span-2">
<Label>IoT设备</Label>
<Select
value={state.newPoint.deviceId || 'none'}
onValueChange={(value) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { deviceId: value === 'none' ? '' : value } })}
>
<SelectTrigger>
<SelectValue placeholder="选择物联网设备" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{state.iotDevices.map((device) => (
<SelectItem key={device.id} value={device.id}>
<div className="flex items-center gap-2">
<span>{device.code} - {device.name}</span>
<Badge variant={device.status === 'online' ? 'default' : 'secondary'} className="ml-2">
{device.status === 'online' ? '在线' : '离线'}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{state.newPoint.deviceId && (() => {
const device = state.iotDevices.find(d => d.id === state.newPoint.deviceId);
return device ? (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="text-xs text-green-800 dark:text-green-200 mb-1">{device.data.lastUpdate}</div>
<div className="grid grid-cols-2 md:grid-cols-6 gap-2 text-xs text-green-700 dark:text-green-300">
<div>pH: {device.data.pH}</div>
<div>: {device.data.organicMatter}</div>
<div>: {device.data.nitrogen}</div>
<div>: {device.data.phosphorus}</div>
<div>: {device.data.potassium}</div>
<div>: {device.data.moisture}%</div>
</div>
</div>
) : null;
})()}
</div>
</div>
{/* GPS坐标定位 */}
<div>
<Label>GPS坐标定位 *</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div>
<Input
type="number"
value={state.newPoint.latitude || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: parseFloat(e.target.value) || 0 } })}
placeholder="纬度"
step="0.000001"
/>
</div>
<div>
<Input
type="number"
value={state.newPoint.longitude || ''}
onChange={(e) => dispatch({ type: 'UPDATE_NEW_POINT', payload: { longitude: parseFloat(e.target.value) || 0 } })}
placeholder="经度"
step="0.000001"
/>
</div>
</div>
<Card className="p-4 bg-muted">
<div className="text-center text-muted-foreground">
<p className="text-sm"></p>
<p className="text-xs mt-1">使</p>
</div>
</Card>
</div>
{/* 分层数据 */}
<div>
<div className="flex items-center justify-between mb-3">
<Label></Label>
<Button type="button" size="sm" variant="outline" onClick={handleAddLayer}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{(state.newPoint.layers || []).map((layer, layerIndex) => (
<Card key={layerIndex} className="p-4">
<div className="flex items-center justify-between mb-3">
<h4> {layerIndex + 1} ({layer.depth})</h4>
{(state.newPoint.layers || []).length > 1 && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRemoveLayer(layerIndex)}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-xs"> *</Label>
<Input
value={layer.depth}
onChange={(e) => {
const updatedLayers = [...(state.newPoint.layers || [])];
updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], depth: e.target.value };
dispatch({ type: 'UPDATE_NEW_POINT', payload: { layers: updatedLayers } });
}}
placeholder="如: 0-20cm"
/>
</div>
<div>
<Label className="text-xs">IoT设备</Label>
<Select
value={layer.deviceId || 'none'}
onValueChange={(value) => handleUpdateLayerDevice(layerIndex, value === 'none' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder="选择设备读取数据" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{state.iotDevices.map((device) => (
<SelectItem key={device.id} value={device.id}>
<span>{device.code} - {device.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{layer.deviceId && (() => {
const device = state.iotDevices.find(d => d.id === layer.deviceId);
return device ? (
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-xs text-blue-800 dark:text-blue-200 mb-2">{device.data.lastUpdate}</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
<div className="flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-600" />
<span>pH: <strong>{device.data.pH}</strong></span>
</div>
<div className="flex items-center gap-2">
<Leaf className="w-4 h-4 text-green-600" />
<span>: <strong>{device.data.organicMatter}</strong> g/kg</span>
</div>
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-blue-600" />
<span>: <strong>{device.data.nitrogen}</strong> g/kg</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-orange-600" />
<span>: <strong>{device.data.phosphorus}</strong> mg/kg</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-600" />
<span>: <strong>{device.data.potassium}</strong> mg/kg</span>
</div>
<div className="flex items-center gap-2">
<Droplets className="w-4 h-4 text-cyan-600" />
<span>: <strong>{device.data.moisture}</strong>%</span>
</div>
</div>
</div>
) : null;
})()}
</Card>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={() => {
dispatch({ type: 'SET_SHOW_EDIT_DIALOG', payload: false });
dispatch({ type: 'SET_SELECTED_POINT', payload: null });
}}>
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleUpdateSamplePoint}
disabled={!state.newPoint.code || !state.newPoint.fieldName || !state.newPoint.sampleDate || !state.newPoint.sampler || !state.newPoint.latitude || !state.newPoint.longitude}
>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { SoilDataState, SoilDataAction, getPHLevel, getOrganicMatterLevel } from './soilDataReducer';
interface LayerDataDialogProps {
state: SoilDataState;
dispatch: React.Dispatch<SoilDataAction>;
}
export default function LayerDataDialog({ state, dispatch }: LayerDataDialogProps) {
if (!state.selectedPoint) return null;
return (
<Dialog open={state.showLayerDialog} onOpenChange={(open) => {
if (!open) {
dispatch({ type: 'SET_SHOW_LAYER_DIALOG', payload: false });
dispatch({ type: 'SET_SELECTED_POINT', payload: null });
}
}}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> - {state.selectedPoint.code}</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 基本信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-muted rounded-lg">
<div>
<span className="text-xs text-muted-foreground">:</span>
<p className="text-sm font-medium">{state.selectedPoint.code}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">:</span>
<p className="text-sm font-medium">{state.selectedPoint.fieldName}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">:</span>
<p className="text-sm font-medium">{state.selectedPoint.sampleDate}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">:</span>
<p className="text-sm font-medium">{state.selectedPoint.sampler}</p>
</div>
</div>
{/* 坐标信息 */}
<div className="grid grid-cols-2 gap-4 p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<div>
<span className="text-xs text-blue-800 dark:text-blue-200">:</span>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{state.selectedPoint.latitude.toFixed(6)}
</p>
</div>
<div>
<span className="text-xs text-blue-800 dark:text-blue-200">:</span>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{state.selectedPoint.longitude.toFixed(6)}
</p>
</div>
</div>
{/* 分层数据表格 */}
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-center text-sm font-medium">pH值</th>
<th className="px-4 py-3 text-center text-sm font-medium"></th>
<th className="px-4 py-3 text-center text-sm font-medium"></th>
<th className="px-4 py-3 text-center text-sm font-medium"></th>
<th className="px-4 py-3 text-center text-sm font-medium"></th>
<th className="px-4 py-3 text-center text-sm font-medium"></th>
</tr>
</thead>
<tbody>
{state.selectedPoint.layers.map((layer, index) => (
<tr key={index} className="border-t">
<td className="px-4 py-3 font-medium">{layer.depth}</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
<span>{layer.pH}</span>
<div className={`w-2 h-2 rounded-full ${getPHLevel(layer.pH || 0).color}`} />
</div>
</td>
<td className="px-4 py-3 text-center">
<span className={getOrganicMatterLevel(layer.organicMatter || 0).color}>
{layer.organicMatter} g/kg
</span>
</td>
<td className="px-4 py-3 text-center">{layer.nitrogen} g/kg</td>
<td className="px-4 py-3 text-center">{layer.phosphorus} mg/kg</td>
<td className="px-4 py-3 text-center">{layer.potassium} mg/kg</td>
<td className="px-4 py-3 text-center">{layer.moisture}%</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 设备绑定信息 */}
{state.selectedPoint.deviceId && (
<div className="p-4 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="text-sm font-medium text-green-900 dark:text-green-100 mb-2">IoT设备</h4>
{(() => {
const device = state.iotDevices.find(d => d.id === state.selectedPoint?.deviceId);
return device ? (
<div className="text-sm text-green-800 dark:text-green-200">
<p><strong>:</strong> {device.code}</p>
<p><strong>:</strong> {device.name}</p>
<p><strong>:</strong> {device.type}</p>
<p><strong>:</strong>
<Badge variant={device.status === 'online' ? 'default' : 'secondary'} className="ml-2">
{device.status === 'online' ? '在线' : '离线'}
</Badge>
</p>
<p className="mt-2"><strong>:</strong> {device.data.lastUpdate}</p>
</div>
) : null;
})()}
</div>
)}
{/* 土壤质量评估 */}
<div className="p-4 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg">
<h4 className="text-sm font-medium text-amber-900 dark:text-amber-100 mb-2"></h4>
<div className="text-sm text-amber-800 dark:text-amber-200 space-y-1">
<p> <strong>pH状况:</strong>
{state.selectedPoint.layers[0]?.pH
? ` 表层pH值为${state.selectedPoint.layers[0].pH},呈${getPHLevel(state.selectedPoint.layers[0].pH).label}反应`
: ' 数据不足,无法评估'
}
</p>
<p> <strong>:</strong>
{state.selectedPoint.layers[0]?.organicMatter
? ` 表层有机质含量为${state.selectedPoint.layers[0].organicMatter} g/kg${getOrganicMatterLevel(state.selectedPoint.layers[0].organicMatter).label}水平`
: ' 数据不足,无法评估'
}
</p>
<p> <strong>:</strong>
{state.selectedPoint.layers[0]?.nitrogen && state.selectedPoint.layers[0]?.phosphorus && state.selectedPoint.layers[0]?.potassium
? ` 氮磷钾含量分别为${state.selectedPoint.layers[0].nitrogen}${state.selectedPoint.layers[0].phosphorus}${state.selectedPoint.layers[0].potassium} mg/kg`
: ' 数据不足,无法评估'
}
</p>
<p> <strong>:</strong>
{state.selectedPoint.layers[0]?.pH && state.selectedPoint.layers[0]?.organicMatter
? (state.selectedPoint.layers[0].pH >= 6.5 && state.selectedPoint.layers[0].pH <= 7.5 && state.selectedPoint.layers[0].organicMatter >= 20
? ' 土壤理化性状良好,适宜大多数作物生长'
: ' 土壤需要改良建议增施有机肥和调节pH值')
: ' 数据不足,无法进行综合评价'
}
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { Card } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { SamplePoint, getPHLevel, getOrganicMatterLevel } from './soilDataReducer';
interface ProfileInformationProps {
samplePoints: SamplePoint[];
}
export default function ProfileInformation({ samplePoints }: ProfileInformationProps) {
const selectedPoint = samplePoints[0]; // 默认选择第一个采样点
if (!selectedPoint) {
return (
<Card className="p-8 text-center">
<div className="text-muted-foreground">
<p></p>
<p className="text-sm mt-1"></p>
</div>
</Card>
);
}
return (
<div className="space-y-4">
<Card className="p-4">
<div className="flex gap-4">
<div className="flex-1">
<label className="text-sm font-medium text-muted-foreground"></label>
<Select defaultValue={selectedPoint.id}>
<SelectTrigger className="w-[300px] mt-1">
<SelectValue placeholder="选择采样点" />
</SelectTrigger>
<SelectContent>
{samplePoints.map((point) => (
<SelectItem key={point.id} value={point.id}>
{point.code} - {point.fieldName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</Card>
{/* 剖面可视化 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="flex flex-col lg:flex-row gap-6">
{/* 左侧剖面图 */}
<div className="w-full lg:w-1/3">
<div className="border-2 border-border rounded-lg overflow-hidden">
{selectedPoint.layers.map((layer, index) => (
<div
key={index}
className="relative border-b last:border-b-0"
style={{
height: '120px',
background: `linear-gradient(to bottom,
${index === 0 ? '#8b7355' : index === 1 ? '#6b5344' : '#4a3829'},
${index === 0 ? '#6b5344' : index === 1 ? '#4a3829' : '#2a1810'}
)`,
}}
>
<div className="absolute left-4 top-4 text-white">
<div className="text-sm font-medium">{layer.depth}</div>
</div>
<div className="absolute right-4 top-4 text-white text-xs bg-black/30 px-2 py-1 rounded">
pH: {layer.pH}
</div>
</div>
))}
</div>
<div className="text-center text-sm text-muted-foreground mt-2"></div>
</div>
{/* 右侧数据表格 */}
<div className="flex-1">
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium"></th>
<th className="px-4 py-3 text-center text-sm font-medium">pH值</th>
<th className="px-4 py-3 text-center text-sm font-medium">(g/kg)</th>
<th className="px-4 py-3 text-center text-sm font-medium">(g/kg)</th>
<th className="px-4 py-3 text-center text-sm font-medium">(mg/kg)</th>
<th className="px-4 py-3 text-center text-sm font-medium">(mg/kg)</th>
<th className="px-4 py-3 text-center text-sm font-medium">(%)</th>
</tr>
</thead>
<tbody>
{selectedPoint.layers.map((layer, index) => (
<tr key={index} className="border-t">
<td className="px-4 py-3 font-medium">{layer.depth}</td>
<td className="px-4 py-3 text-center">
<Badge className={`${getPHLevel(layer.pH || 0).color} text-white`}>
{layer.pH}
</Badge>
</td>
<td className="px-4 py-3 text-center">
<span className={getOrganicMatterLevel(layer.organicMatter || 0).color}>
{layer.organicMatter}
</span>
</td>
<td className="px-4 py-3 text-center">{layer.nitrogen}</td>
<td className="px-4 py-3 text-center">{layer.phosphorus}</td>
<td className="px-4 py-3 text-center">{layer.potassium}</td>
<td className="px-4 py-3 text-center">{layer.moisture}%</td>
</tr>
))}
</tbody>
</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">
<h4 className="mb-2 text-blue-900 dark:text-blue-100 font-medium"></h4>
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
<li> <strong>pH值</strong>: {selectedPoint.layers[0]?.pH > (selectedPoint.layers[selectedPoint.layers.length - 1]?.pH || 0) ? '下降' : '上升'}{getPHLevel(selectedPoint.layers[0]?.pH || 0).label}</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: 20cm以下显著降低</li>
<li> <strong></strong>: </li>
</ul>
</div>
</div>
</div>
</Card>
{/* 分层数据详情 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{selectedPoint.layers.map((layer, index) => (
<div key={index} className="border rounded-lg p-4 bg-card">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium text-lg"> {index + 1} </h4>
<Badge variant="outline" className="font-light">{layer.depth}</Badge>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">pH值:</span>
<div className="flex items-center gap-2">
<span className="font-medium">{layer.pH}</span>
<div className={`w-2 h-2 rounded-full ${getPHLevel(layer.pH || 0).color}`} />
</div>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">:</span>
<span className="font-medium">{layer.organicMatter} g/kg</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">:</span>
<span className="font-medium">{layer.nitrogen} g/kg</span>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">:</span>
<span className="font-medium">{layer.phosphorus} mg/kg</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">:</span>
<span className="font-medium">{layer.potassium} mg/kg</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">:</span>
<span className="font-medium">{layer.moisture}%</span>
</div>
</div>
</div>
{/* 营养状况评估 */}
<div className="mt-4 pt-4 border-t">
<div className="text-sm">
<span className="text-muted-foreground">: </span>
<span className="font-medium text-green-600">
{layer.organicMatter && layer.organicMatter > 25 ? '营养丰富' :
layer.organicMatter && layer.organicMatter > 15 ? '营养中等' : '营养偏低'}
</span>
</div>
</div>
</div>
))}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Search, MapPin, Layers, Edit, Trash2, Leaf, Zap, TrendingUp, Droplets } from 'lucide-react';
import { SoilDataState, SoilDataAction, getPHLevel } from './soilDataReducer';
interface SamplePointsListProps {
state: SoilDataState;
dispatch: React.Dispatch<SoilDataAction>;
}
export default function SamplePointsList({ state, dispatch }: SamplePointsListProps) {
const handleViewLayers = (point: any) => {
dispatch({ type: 'SET_SELECTED_POINT', payload: point });
dispatch({ type: 'SET_SHOW_LAYER_DIALOG', payload: true });
};
const handleEditPoint = (point: any) => {
dispatch({ type: 'SET_SELECTED_POINT', payload: point });
dispatch({ type: 'SET_NEW_POINT', payload: point });
dispatch({ type: 'SET_SHOW_EDIT_DIALOG', payload: true });
};
const handleDeleteClick = (pointId: string) => {
dispatch({ type: 'SET_POINT_TO_DELETE', payload: pointId });
dispatch({ type: 'SET_SHOW_DELETE_DIALOG', payload: true });
};
const handleSearchChange = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { searchKeyword: value } });
};
const handleFieldChange = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { selectedField: value } });
};
// 筛选采样点
const filteredPoints = state.samplePoints.filter(point => {
const matchesSearch = !state.filters.searchKeyword ||
point.code.toLowerCase().includes(state.filters.searchKeyword.toLowerCase()) ||
point.fieldName.toLowerCase().includes(state.filters.searchKeyword.toLowerCase());
const matchesField = state.filters.selectedField === 'all' ||
point.fieldName === state.filters.selectedField;
return matchesSearch && matchesField;
});
return (
<div className="space-y-4">
{/* 搜索和筛选 */}
<Card className="p-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索采样点编号、地块名称..."
value={state.filters.searchKeyword}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={state.filters.selectedField} onValueChange={handleFieldChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="选择地块" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Array.from(new Set(state.samplePoints.map(p => p.fieldName))).map(fieldName => (
<SelectItem key={fieldName} value={fieldName}>
{fieldName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</Card>
{/* 采样点列表 */}
<div className="space-y-3">
{filteredPoints.map((point) => (
<Card key={point.id} className="p-4 bg-card hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<MapPin className="w-5 h-5 text-green-600" />
<div>
<div className="flex items-center gap-2">
<h4 className="font-semibold">{point.code}</h4>
<Badge variant="outline" className="font-light">{point.fieldName}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
: {point.latitude.toFixed(6)}, {point.longitude.toFixed(6)}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pl-8">
<div>
<span className="text-xs text-muted-foreground"></span>
<p className="text-sm mt-1">{point.sampleDate}</p>
</div>
<div>
<span className="text-xs text-muted-foreground"></span>
<p className="text-sm mt-1">{point.sampler}</p>
</div>
<div>
<span className="text-xs text-muted-foreground"></span>
<p className="text-sm mt-1">{point.layers.length} </p>
</div>
<div>
<span className="text-xs text-muted-foreground">pH值</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-sm">{point.layers[0]?.pH}</span>
<div className={`w-2 h-2 rounded-full ${getPHLevel(point.layers[0]?.pH || 0).color}`} />
</div>
</div>
</div>
{/* 表层指标快览 */}
{point.layers[0] && (
<div className="mt-3 p-3 bg-muted rounded-lg">
<div className="text-xs text-muted-foreground mb-2">0-20cm</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
<div className="flex items-center gap-2">
<Leaf className="w-4 h-4 text-green-600" />
<span>: <strong>{point.layers[0].organicMatter}</strong> g/kg</span>
</div>
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-blue-600" />
<span>: <strong>{point.layers[0].nitrogen}</strong> g/kg</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-orange-600" />
<span>: <strong>{point.layers[0].phosphorus}</strong> mg/kg</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-600" />
<span>: <strong>{point.layers[0].potassium}</strong> mg/kg</span>
</div>
<div className="flex items-center gap-2">
<Droplets className="w-4 h-4 text-cyan-600" />
<span>: <strong>{point.layers[0].moisture}</strong>%</span>
</div>
</div>
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => handleViewLayers(point)}>
<Layers className="w-4 h-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={() => handleEditPoint(point)}>
<Edit className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => handleDeleteClick(point.id)}>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
</Card>
))}
{filteredPoints.length === 0 && (
<Card className="p-8 text-center">
<div className="text-muted-foreground">
<MapPin className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p></p>
<p className="text-sm mt-1">"新增采样点"</p>
</div>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,215 @@
'use client';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Download, FileText, Plus, MapPin, Layers, BarChart3, Droplets } from 'lucide-react';
import { SoilDataState, SoilDataAction } from './soilDataReducer';
import StatisticsCards from './StatisticsCards';
import SamplePointsList from './SamplePointsList';
import SpatialDistribution from './SpatialDistribution';
import ProfileInformation from './ProfileInformation';
import StatisticalAnalysis from './StatisticalAnalysis';
import AddSamplePointDialog from './AddSamplePointDialog';
import EditSamplePointDialog from './EditSamplePointDialog';
import LayerDataDialog from './LayerDataDialog';
import DeleteConfirmDialog from './DeleteConfirmDialog';
import UsageGuide from './UsageGuide';
interface SoilDataContentProps {
state: SoilDataState;
dispatch: React.Dispatch<SoilDataAction>;
}
export default function SoilDataContent({ state, dispatch }: SoilDataContentProps) {
const handleExportData = () => {
// 生成CSV数据
const headers = ['采样点编号', '地块', '纬度', '经度', '采样日期', '采样人', '深度', 'pH值', '有机质(g/kg)', '全氮(g/kg)', '有效磷(mg/kg)', '速效钾(mg/kg)', '含水量(%)'];
const rows = state.samplePoints.flatMap(point =>
point.layers.map(layer => [
point.code,
point.fieldName,
point.latitude,
point.longitude,
point.sampleDate,
point.sampler,
layer.depth,
layer.pH,
layer.organicMatter,
layer.nitrogen,
layer.phosphorus,
layer.potassium,
layer.moisture,
])
);
const csvContent = [
headers.join(','),
...rows.map(row => row.join(',')),
].join('\n');
// 创建下载链接
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `土壤基础数据_${new Date().toLocaleDateString()}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleGenerateReport = () => {
// 生成报告HTML内容
const reportHTML = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>土壤检测报告</title>
<style>
body { font-family: Arial, sans-serif; padding: 40px; }
h1 { color: #2d5016; border-bottom: 3px solid #4ade80; padding-bottom: 10px; }
h2 { color: #16a34a; margin-top: 30px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #f0fdf4; color: #166534; }
tr:nth-child(even) { background-color: #f9fafb; }
.summary { background: #f0fdf4; padding: 20px; border-radius: 8px; margin: 20px 0; }
.footer { margin-top: 40px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<h1>土壤基础数据检测报告</h1>
<div class="summary">
<h3>报告概要</h3>
<p><strong>生成日期:</strong>${new Date().toLocaleDateString()}</p>
<p><strong>采样点总数:</strong>${state.samplePoints.length} 个</p>
<p><strong>覆盖地块:</strong>${state.statistics?.totalFields || 0} 个</p>
<p><strong>分层样本:</strong>${state.statistics?.totalLayers || 0} 层</p>
</div>
<h2>采样点详细数据</h2>
${state.samplePoints.map(point => `
<h3>${point.code} - ${point.fieldName}</h3>
<p><strong>坐标:</strong>${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}</p>
<p><strong>采样日期:</strong>${point.sampleDate} | <strong>采样人:</strong>${point.sampler}</p>
<table>
<thead>
<tr>
<th>深度</th>
<th>pH值</th>
<th>有机质(g/kg)</th>
<th>全氮(g/kg)</th>
<th>有效磷(mg/kg)</th>
<th>速效钾(mg/kg)</th>
<th>含水量(%)</th>
</tr>
</thead>
<tbody>
${point.layers.map(layer => `
<tr>
<td>${layer.depth}</td>
<td>${layer.pH}</td>
<td>${layer.organicMatter}</td>
<td>${layer.nitrogen}</td>
<td>${layer.phosphorus}</td>
<td>${layer.potassium}</td>
<td>${layer.moisture}</td>
</tr>
`).join('')}
</tbody>
</table>
`).join('')}
<div class="footer">
<p>本报告由智慧农业生产管理系统自动生成</p>
<p>报告生成时间:${new Date().toLocaleString()}</p>
</div>
</body>
</html>
`;
// 创建下载链接
const blob = new Blob([reportHTML], { type: 'text/html;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `土壤检测报告_${new Date().toLocaleDateString()}.html`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="space-y-6">
{/* 操作按钮区域 */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200">
</h3>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleExportData}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleGenerateReport}>
<FileText className="w-4 h-4 mr-2" />
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => dispatch({ type: 'SET_SHOW_ADD_DIALOG', payload: true })}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 统计卡片 */}
<StatisticsCards statistics={state.statistics} />
{/* 主要内容区域 */}
<Tabs value={state.activeTab} onValueChange={(value) => dispatch({ type: 'SET_ACTIVE_TAB', payload: value })}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="list"></TabsTrigger>
<TabsTrigger value="distribution"></TabsTrigger>
<TabsTrigger value="profile"></TabsTrigger>
<TabsTrigger value="statistics"></TabsTrigger>
</TabsList>
<TabsContent value="list" className="space-y-4">
<SamplePointsList state={state} dispatch={dispatch} />
</TabsContent>
<TabsContent value="distribution" className="space-y-4">
<SpatialDistribution samplePoints={state.samplePoints} />
</TabsContent>
<TabsContent value="profile" className="space-y-4">
<ProfileInformation samplePoints={state.samplePoints} />
</TabsContent>
<TabsContent value="statistics" className="space-y-4">
<StatisticalAnalysis statistics={state.statistics} />
</TabsContent>
</Tabs>
{/* 对话框组件 */}
<AddSamplePointDialog state={state} dispatch={dispatch} />
<EditSamplePointDialog state={state} dispatch={dispatch} />
<LayerDataDialog state={state} dispatch={dispatch} />
<DeleteConfirmDialog state={state} dispatch={dispatch} />
{/* 使用说明 */}
<UsageGuide />
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SamplePoint } from './soilDataReducer';
interface SpatialDistributionProps {
samplePoints: SamplePoint[];
}
export default function SpatialDistribution({ samplePoints }: SpatialDistributionProps) {
// 计算地图中心点
const getCenterPoint = () => {
if (samplePoints.length === 0) {
return { lat: 39.9042, lng: 116.4074 }; // 默认北京天安门
}
const avgLat = samplePoints.reduce((sum, p) => sum + p.latitude, 0) / samplePoints.length;
const avgLng = samplePoints.reduce((sum, p) => sum + p.longitude, 0) / samplePoints.length;
return { lat: avgLat, lng: avgLng };
};
const center = getCenterPoint();
return (
<div className="space-y-4">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
{/* 地图容器 - 暂时用占位符替代真实地图 */}
<div className="relative h-[500px] rounded-lg border bg-muted overflow-hidden">
{/* 地图占位符 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<div className="w-16 h-16 bg-muted-foreground/20 rounded-full mx-auto mb-4 flex items-center justify-center">
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<p className="text-sm">...</p>
<p className="text-xs mt-1">: {center.lat.toFixed(4)}, {center.lng.toFixed(4)}</p>
</div>
</div>
{/* 模拟采样点标记 */}
{samplePoints.map((point, index) => {
const relativeLat = ((point.latitude - center.lat) * 10000 + 50) % 100;
const relativeLng = ((point.longitude - center.lng) * 10000 + 50) % 100;
return (
<div
key={point.id}
className="absolute w-4 h-4 rounded-full border-2 border-white shadow-lg cursor-pointer hover:scale-125 transition-transform"
style={{
left: `${relativeLng}%`,
top: `${relativeLat}%`,
backgroundColor: point.layers[0]?.pH < 6.5 ? '#f97316' : point.layers[0]?.pH < 7.5 ? '#22c55e' : '#3b82f6',
}}
title={`${point.code} - ${point.fieldName}\npH: ${point.layers[0]?.pH}`}
/>
);
})}
{/* 图例 */}
<div className="absolute bottom-4 right-4 bg-background p-4 rounded-lg shadow-lg border z-10">
<h4 className="text-sm font-medium mb-2"></h4>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-orange-500" />
<span> (pH &lt; 6.5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span> (6.5-7.5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span> (pH &gt; 7.5)</span>
</div>
</div>
</div>
</div>
</Card>
{/* 空间分布统计 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4">
<h4 className="font-medium mb-3"></h4>
<div className="space-y-2 text-sm">
{Array.from(new Set(samplePoints.map(p => p.fieldName))).map(fieldName => {
const fieldPoints = samplePoints.filter(p => p.fieldName === fieldName);
return (
<div key={fieldName} className="flex justify-between">
<span className="text-muted-foreground">{fieldName}:</span>
<span>{fieldPoints.length} </span>
</div>
);
})}
</div>
</Card>
<Card className="p-4">
<h4 className="font-medium mb-3">pH值分布</h4>
<div className="space-y-2">
{[
{ label: '酸性', color: 'bg-orange-500', count: samplePoints.filter(p => p.layers[0]?.pH < 6.5).length },
{ label: '中性', color: 'bg-green-500', count: samplePoints.filter(p => p.layers[0]?.pH >= 6.5 && p.layers[0]?.pH < 7.5).length },
{ label: '碱性', color: 'bg-blue-500', count: samplePoints.filter(p => p.layers[0]?.pH >= 7.5).length },
].map(item => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${item.color}`} />
<span className="text-sm">{item.label}</span>
</div>
<Badge variant="outline" className="font-light">{item.count}</Badge>
</div>
))}
</div>
</Card>
<Card className="p-4">
<h4 className="font-medium mb-3"></h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>{samplePoints.length} </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>{new Set(samplePoints.map(p => p.fieldName)).size} </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>{samplePoints.reduce((sum, p) => sum + p.layers.length, 0)} </span>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SoilDataStatistics } from './soilDataReducer';
interface StatisticalAnalysisProps {
statistics: SoilDataStatistics | null;
}
export default function StatisticalAnalysis({ statistics }: StatisticalAnalysisProps) {
if (!statistics) {
return (
<Card className="p-8 text-center">
<div className="text-muted-foreground">
<p></p>
<p className="text-sm mt-1"></p>
</div>
</Card>
);
}
const totalSamples = statistics.phDistribution.strongAcidic +
statistics.phDistribution.acidic +
statistics.phDistribution.neutral +
statistics.phDistribution.alkaline +
statistics.phDistribution.strongAlkaline;
const calculatePercentage = (value: number) => {
return totalSamples > 0 ? ((value / totalSamples) * 100).toFixed(0) : '0';
};
return (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* pH值分布统计 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">pH值分布统计</h3>
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-1">
<span> (&lt;5.5)</span>
<span>{statistics.phDistribution.strongAcidic} ({calculatePercentage(statistics.phDistribution.strongAcidic)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-red-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.phDistribution.strongAcidic)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (5.5-6.5)</span>
<span>{statistics.phDistribution.acidic} ({calculatePercentage(statistics.phDistribution.acidic)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.phDistribution.acidic)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (6.5-7.5)</span>
<span>{statistics.phDistribution.neutral} ({calculatePercentage(statistics.phDistribution.neutral)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.phDistribution.neutral)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (7.5-8.5)</span>
<span>{statistics.phDistribution.alkaline} ({calculatePercentage(statistics.phDistribution.alkaline)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.phDistribution.alkaline)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (&gt;8.5)</span>
<span>{statistics.phDistribution.strongAlkaline} ({calculatePercentage(statistics.phDistribution.strongAlkaline)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.phDistribution.strongAlkaline)}%` }}
/>
</div>
</div>
</div>
{/* pH值评估 */}
<div className="mt-4 p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="text-sm text-green-800 dark:text-green-200">
<strong>pH值评估</strong>
{statistics.phDistribution.neutral > totalSamples * 0.6 ?
' 土壤酸碱度适中,适宜大多数作物生长' :
statistics.phDistribution.acidic > totalSamples * 0.5 ?
' 土壤偏酸性,建议适量施用石灰调节' :
statistics.phDistribution.alkaline > totalSamples * 0.5 ?
' 土壤偏碱性,建议适量施用酸性肥料调节' :
' 土壤酸碱度需要进一步检测和调节'
}
</div>
</div>
</Card>
{/* 有机质含量统计 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-1">
<span> (&lt;10 g/kg)</span>
<span>{statistics.organicMatterDistribution.veryLow} ({calculatePercentage(statistics.organicMatterDistribution.veryLow)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-red-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.organicMatterDistribution.veryLow)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (10-20 g/kg)</span>
<span>{statistics.organicMatterDistribution.low} ({calculatePercentage(statistics.organicMatterDistribution.low)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.organicMatterDistribution.low)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (20-30 g/kg)</span>
<span>{statistics.organicMatterDistribution.medium} ({calculatePercentage(statistics.organicMatterDistribution.medium)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-yellow-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.organicMatterDistribution.medium)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (30-40 g/kg)</span>
<span>{statistics.organicMatterDistribution.high} ({calculatePercentage(statistics.organicMatterDistribution.high)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.organicMatterDistribution.high)}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span> (&gt;40 g/kg)</span>
<span>{statistics.organicMatterDistribution.veryHigh} ({calculatePercentage(statistics.organicMatterDistribution.veryHigh)}%)</span>
</div>
<div className="w-full h-6 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${calculatePercentage(statistics.organicMatterDistribution.veryHigh)}%` }}
/>
</div>
</div>
</div>
{/* 有机质评估 */}
<div className="mt-4 p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="text-sm text-green-800 dark:text-green-200">
<strong></strong>
{(statistics.organicMatterDistribution.high + statistics.organicMatterDistribution.veryHigh) > totalSamples * 0.5 ?
' 土壤有机质含量丰富,肥力良好' :
(statistics.organicMatterDistribution.medium) > totalSamples * 0.5 ?
' 土壤有机质含量中等,需要适量补充' :
' 土壤有机质含量偏低,建议增施有机肥'
}
</div>
</div>
</Card>
</div>
{/* 综合统计表格 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center p-4 bg-muted rounded-lg">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{statistics.totalPoints}
</div>
<div className="text-sm text-muted-foreground mt-1"></div>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{statistics.totalFields}
</div>
<div className="text-sm text-muted-foreground mt-1"></div>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{statistics.totalLayers}
</div>
<div className="text-sm text-muted-foreground mt-1"></div>
</div>
<div className="text-center p-4 bg-muted rounded-lg">
<div className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{statistics.averagePH.toFixed(1)}
</div>
<div className="text-sm text-muted-foreground mt-1">pH值</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">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2"></h4>
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<p> <strong></strong> {statistics.totalFields} {statistics.totalPoints > 10 ? '充足' : '适中'}</p>
<p> <strong></strong>pH值{statistics.averagePH.toFixed(1)}{statistics.averagePH < 6.5 ? '酸性' : statistics.averagePH > 7.5 ? '碱性' : '中性'}</p>
<p> <strong></strong>
{statistics.phDistribution.neutral > totalSamples * 0.5 ?
' 土壤酸碱度状况良好,继续保持当前管理方式' :
' 建议进行土壤改良调节pH值至适宜范围'
}
</p>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { Card } from '@/components/ui/card';
import { MapPin, Layers, BarChart3, Droplets } from 'lucide-react';
import { SoilDataStatistics } from './soilDataReducer';
interface StatisticsCardsProps {
statistics: SoilDataStatistics | null;
}
export default function StatisticsCards({ statistics }: StatisticsCardsProps) {
if (!statistics) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="p-4 animate-pulse">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-20"></div>
<div className="h-6 bg-muted rounded w-12"></div>
</div>
<div className="w-10 h-10 bg-muted rounded"></div>
</div>
</Card>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-2 text-2xl text-green-600 dark:text-green-400 font-semibold">
{statistics.totalPoints}
</p>
</div>
<MapPin className="w-10 h-10 text-green-600 dark:text-green-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-2 text-2xl text-blue-600 dark:text-blue-400 font-semibold">
{statistics.totalFields}
</p>
</div>
<Layers className="w-10 h-10 text-blue-600 dark:text-blue-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-2 text-2xl text-purple-600 dark:text-purple-400 font-semibold">
{statistics.totalLayers}
</p>
</div>
<BarChart3 className="w-10 h-10 text-purple-600 dark:text-purple-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">pH值</p>
<p className="mt-2 text-2xl text-orange-600 dark:text-orange-400 font-semibold">
{statistics.averagePH.toFixed(1)}
</p>
</div>
<Droplets className="w-10 h-10 text-orange-600 dark:text-orange-400 opacity-50" />
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { Card } from '@/components/ui/card';
import { AlertCircle } from 'lucide-react';
export default function UsageGuide() {
return (
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="mb-2 font-medium"></p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: GPS坐标</li>
<li> <strong></strong>: 0-20cm20-40cm40-60cm等</li>
<li> <strong></strong>: pH值</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: CSV格式</li>
<li> <strong>IoT集成</strong>: </li>
</ul>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,500 @@
'use client';
import { toast } from 'sonner';
// 土壤采样点接口
export interface SoilLayer {
depth: string; // 如 "0-20cm"
deviceId?: string; // IoT设备ID用于读取该层数据
// 以下字段从设备读取,仅用于展示
pH?: number;
organicMatter?: number; // 有机质 g/kg
nitrogen?: number; // 全氮 g/kg
phosphorus?: number; // 有效磷 mg/kg
potassium?: number; // 速效钾 mg/kg
moisture?: number; // 含水量 %
}
export interface SamplePoint {
id: string;
code: string;
fieldName: string;
latitude: number;
longitude: number;
sampleDate: string;
sampler: string;
deviceId?: string; // IoT设备ID
layers: SoilLayer[];
}
// IoT设备接口
export interface IoTDevice {
id: string;
code: string;
name: string;
type: string;
status: 'online' | 'offline';
// 实时监测数据
data: {
pH: number;
organicMatter: number;
nitrogen: number;
phosphorus: number;
potassium: number;
moisture: number;
lastUpdate: string;
};
}
// 筛选条件接口
export interface SoilDataFilters {
searchKeyword: string;
selectedField: string;
dateRange: {
start: string;
end: string;
};
}
// 统计结果接口
export interface SoilDataStatistics {
totalPoints: number;
totalFields: number;
totalLayers: number;
averagePH: number;
phDistribution: {
strongAcidic: number; // 强酸性
acidic: number; // 酸性
neutral: number; // 中性
alkaline: number; // 碱性
strongAlkaline: number; // 强碱性
};
organicMatterDistribution: {
veryLow: number; // 极低
low: number; // 低
medium: number; // 中等
high: number; // 较高
veryHigh: number; // 高
};
}
// 状态接口
export interface SoilDataState {
samplePoints: SamplePoint[];
iotDevices: IoTDevice[];
filters: SoilDataFilters;
statistics: SoilDataStatistics | null;
activeTab: string;
showAddDialog: boolean;
showEditDialog: boolean;
showLayerDialog: boolean;
showDeleteDialog: boolean;
selectedPoint: SamplePoint | null;
pointToDelete: string | null;
newPoint: Partial<SamplePoint>;
loading: boolean;
}
// Action类型
export type SoilDataAction =
| { type: 'SET_SAMPLE_POINTS'; payload: SamplePoint[] }
| { type: 'SET_IOT_DEVICES'; payload: IoTDevice[] }
| { type: 'SET_FILTERS'; payload: Partial<SoilDataFilters> }
| { type: 'SET_STATISTICS'; payload: SoilDataStatistics | null }
| { type: 'SET_ACTIVE_TAB'; payload: string }
| { type: 'SET_SHOW_ADD_DIALOG'; payload: boolean }
| { type: 'SET_SHOW_EDIT_DIALOG'; payload: boolean }
| { type: 'SET_SHOW_LAYER_DIALOG'; payload: boolean }
| { type: 'SET_SHOW_DELETE_DIALOG'; payload: boolean }
| { type: 'SET_SELECTED_POINT'; payload: SamplePoint | null }
| { type: 'SET_POINT_TO_DELETE'; payload: string | null }
| { type: 'SET_NEW_POINT'; payload: Partial<SamplePoint> }
| { type: 'UPDATE_NEW_POINT'; payload: Partial<SamplePoint> }
| { type: 'RESET_NEW_POINT' }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'ADD_SAMPLE_POINT'; payload: SamplePoint }
| { type: 'UPDATE_SAMPLE_POINT'; payload: SamplePoint }
| { type: 'DELETE_SAMPLE_POINT'; payload: string }
| { type: 'LOAD_FROM_STORAGE' }
| { type: 'SAVE_TO_STORAGE' };
// 初始状态
export const initialSoilDataState: SoilDataState = {
samplePoints: [],
iotDevices: [],
filters: {
searchKeyword: '',
selectedField: 'all',
dateRange: {
start: '',
end: ''
}
},
statistics: null,
activeTab: 'list',
showAddDialog: false,
showEditDialog: false,
showLayerDialog: false,
showDeleteDialog: false,
selectedPoint: null,
pointToDelete: null,
newPoint: {
code: '',
fieldName: '',
latitude: 0,
longitude: 0,
sampleDate: '',
sampler: '',
deviceId: '',
layers: [
{ depth: '0-20cm', deviceId: '' },
{ depth: '20-40cm', deviceId: '' },
{ depth: '40-60cm', deviceId: '' },
]
},
loading: false
};
// 初始化测试数据
const initializeTestData = () => {
const testDevices: IoTDevice[] = [
{
id: 'dev-1',
code: 'IOT-SOIL-001',
name: '土壤传感器001',
type: '多参数土壤传感器',
status: 'online',
data: {
pH: 6.8,
organicMatter: 28.5,
nitrogen: 1.2,
phosphorus: 25.3,
potassium: 180,
moisture: 22.5,
lastUpdate: '2024-10-18 14:30:00',
},
},
{
id: 'dev-2',
code: 'IOT-SOIL-002',
name: '土壤传感器002',
type: '多参数土壤传感器',
status: 'online',
data: {
pH: 7.2,
organicMatter: 32.1,
nitrogen: 1.5,
phosphorus: 28.6,
potassium: 195,
moisture: 20.3,
lastUpdate: '2024-10-18 14:28:00',
},
},
{
id: 'dev-3',
code: 'IOT-SOIL-003',
name: '土壤传感器003',
type: '多参数土壤传感器',
status: 'online',
data: {
pH: 5.8,
organicMatter: 22.3,
nitrogen: 0.9,
phosphorus: 18.5,
potassium: 152,
moisture: 24.6,
lastUpdate: '2024-10-18 14:25:00',
},
},
{
id: 'dev-4',
code: 'IOT-SOIL-004',
name: '土壤传感器004',
type: '多参数土壤传感器',
status: 'offline',
data: {
pH: 6.5,
organicMatter: 18.2,
nitrogen: 0.8,
phosphorus: 18.5,
potassium: 145,
moisture: 25.8,
lastUpdate: '2024-10-17 18:30:00',
},
},
];
const testPoints: SamplePoint[] = [
{
id: 'sp-1',
code: 'SP001',
fieldName: '东区1号地',
latitude: 39.9042,
longitude: 116.4074,
sampleDate: '2024-10-15',
sampler: '张三',
deviceId: 'dev-1',
layers: [
{ depth: '0-20cm', deviceId: 'dev-1', pH: 6.8, organicMatter: 28.5, nitrogen: 1.2, phosphorus: 25.3, potassium: 180, moisture: 22.5 },
{ depth: '20-40cm', deviceId: 'dev-1', pH: 6.5, organicMatter: 18.2, nitrogen: 0.8, phosphorus: 18.5, potassium: 145, moisture: 25.8 },
{ depth: '40-60cm', deviceId: 'dev-1', pH: 6.3, organicMatter: 12.5, nitrogen: 0.5, phosphorus: 12.3, potassium: 98, moisture: 28.2 },
],
},
{
id: 'sp-2',
code: 'SP002',
fieldName: '东区1号地',
latitude: 39.9052,
longitude: 116.4084,
sampleDate: '2024-10-15',
sampler: '张三',
deviceId: 'dev-2',
layers: [
{ depth: '0-20cm', deviceId: 'dev-2', pH: 7.2, organicMatter: 32.1, nitrogen: 1.5, phosphorus: 28.6, potassium: 195, moisture: 20.3 },
{ depth: '20-40cm', deviceId: 'dev-2', pH: 6.8, organicMatter: 22.3, nitrogen: 1.0, phosphorus: 21.2, potassium: 158, moisture: 23.5 },
],
},
{
id: 'sp-3',
code: 'SP003',
fieldName: '西区2号地',
latitude: 39.9032,
longitude: 116.4064,
sampleDate: '2024-10-14',
sampler: '李四',
deviceId: 'dev-3',
layers: [
{ depth: '0-20cm', deviceId: 'dev-3', pH: 5.8, organicMatter: 22.3, nitrogen: 0.9, phosphorus: 18.5, potassium: 152, moisture: 24.6 },
{ depth: '20-40cm', deviceId: 'dev-3', pH: 5.5, organicMatter: 15.8, nitrogen: 0.6, phosphorus: 14.2, potassium: 125, moisture: 26.8 },
{ depth: '40-60cm', deviceId: 'dev-3', pH: 5.3, organicMatter: 10.2, nitrogen: 0.4, phosphorus: 9.8, potassium: 88, moisture: 29.1 },
],
},
];
return { devices: testDevices, points: testPoints };
};
// 计算统计数据
const calculateStatistics = (points: SamplePoint[]): SoilDataStatistics => {
const totalPoints = points.length;
const uniqueFields = new Set(points.map(p => p.fieldName));
const totalFields = uniqueFields.size;
const totalLayers = points.reduce((sum, p) => sum + p.layers.length, 0);
// 计算平均pH值
const phValues = points.flatMap(p => p.layers.map(l => l.pH || 0));
const averagePH = phValues.length > 0 ? phValues.reduce((sum, ph) => sum + ph, 0) / phValues.length : 0;
// pH分布统计
const phDistribution = phValues.reduce((acc, ph) => {
if (ph < 5.5) acc.strongAcidic++;
else if (ph < 6.5) acc.acidic++;
else if (ph < 7.5) acc.neutral++;
else if (ph < 8.5) acc.alkaline++;
else acc.strongAlkaline++;
return acc;
}, { strongAcidic: 0, acidic: 0, neutral: 0, alkaline: 0, strongAlkaline: 0 });
// 有机质分布统计
const organicMatterValues = points.flatMap(p => p.layers.map(l => l.organicMatter || 0));
const organicMatterDistribution = organicMatterValues.reduce((acc, om) => {
if (om < 10) acc.veryLow++;
else if (om < 20) acc.low++;
else if (om < 30) acc.medium++;
else if (om < 40) acc.high++;
else acc.veryHigh++;
return acc;
}, { veryLow: 0, low: 0, medium: 0, high: 0, veryHigh: 0 });
return {
totalPoints,
totalFields,
totalLayers,
averagePH,
phDistribution,
organicMatterDistribution
};
};
// Reducer函数
export function soilDataReducer(state: SoilDataState, action: SoilDataAction): SoilDataState {
switch (action.type) {
case 'SET_SAMPLE_POINTS':
return {
...state,
samplePoints: action.payload,
statistics: calculateStatistics(action.payload)
};
case 'SET_IOT_DEVICES':
return { ...state, iotDevices: action.payload };
case 'SET_FILTERS':
return {
...state,
filters: { ...state.filters, ...action.payload }
};
case 'SET_STATISTICS':
return { ...state, statistics: action.payload };
case 'SET_ACTIVE_TAB':
return { ...state, activeTab: action.payload };
case 'SET_SHOW_ADD_DIALOG':
if (action.payload) {
return {
...state,
showAddDialog: action.payload,
newPoint: {
code: '',
fieldName: '',
latitude: 0,
longitude: 0,
sampleDate: '',
sampler: '',
deviceId: '',
layers: [
{ depth: '0-20cm', deviceId: '' },
{ depth: '20-40cm', deviceId: '' },
{ depth: '40-60cm', deviceId: '' },
]
}
};
}
return { ...state, showAddDialog: action.payload };
case 'SET_SHOW_EDIT_DIALOG':
return { ...state, showEditDialog: action.payload };
case 'SET_SHOW_LAYER_DIALOG':
return { ...state, showLayerDialog: action.payload };
case 'SET_SHOW_DELETE_DIALOG':
return { ...state, showDeleteDialog: action.payload };
case 'SET_SELECTED_POINT':
return { ...state, selectedPoint: action.payload };
case 'SET_POINT_TO_DELETE':
return { ...state, pointToDelete: action.payload };
case 'SET_NEW_POINT':
return { ...state, newPoint: action.payload };
case 'UPDATE_NEW_POINT':
return {
...state,
newPoint: { ...state.newPoint, ...action.payload }
};
case 'RESET_NEW_POINT':
return {
...state,
newPoint: {
code: '',
fieldName: '',
latitude: 0,
longitude: 0,
sampleDate: '',
sampler: '',
deviceId: '',
layers: [
{ depth: '0-20cm', deviceId: '' },
{ depth: '20-40cm', deviceId: '' },
{ depth: '40-60cm', deviceId: '' },
]
}
};
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'ADD_SAMPLE_POINT':
const updatedPoints = [...state.samplePoints, action.payload];
toast.success('采样点添加成功!');
return {
...state,
samplePoints: updatedPoints,
statistics: calculateStatistics(updatedPoints)
};
case 'UPDATE_SAMPLE_POINT':
const updatedPointsList = state.samplePoints.map(point =>
point.id === action.payload.id ? action.payload : point
);
toast.success('采样点更新成功!');
return {
...state,
samplePoints: updatedPointsList,
statistics: calculateStatistics(updatedPointsList)
};
case 'DELETE_SAMPLE_POINT':
const filteredPoints = state.samplePoints.filter(p => p.id !== action.payload);
toast.success('采样点已删除');
return {
...state,
samplePoints: filteredPoints,
statistics: calculateStatistics(filteredPoints)
};
case 'LOAD_FROM_STORAGE':
try {
const stored = localStorage.getItem('soilData');
if (stored) {
const parsedData = JSON.parse(stored);
return {
...state,
samplePoints: parsedData.samplePoints || [],
statistics: calculateStatistics(parsedData.samplePoints || [])
};
} else {
// 首次加载,初始化测试数据
const testData = initializeTestData();
localStorage.setItem('soilData', JSON.stringify({
samplePoints: testData.points
}));
return {
...state,
samplePoints: testData.points,
iotDevices: testData.devices,
statistics: calculateStatistics(testData.points)
};
}
} catch (error) {
console.error('Failed to load soil data from storage:', error);
return state;
}
case 'SAVE_TO_STORAGE':
try {
localStorage.setItem('soilData', JSON.stringify({
samplePoints: state.samplePoints
}));
} catch (error) {
console.error('Failed to save soil data to storage:', error);
}
return state;
default:
return state;
}
}
// 工具函数
export const getPHLevel = (pH: number) => {
if (pH < 5.5) return { label: '强酸性', color: 'bg-red-500' };
if (pH < 6.5) return { label: '酸性', color: 'bg-orange-500' };
if (pH < 7.5) return { label: '中性', color: 'bg-green-500' };
if (pH < 8.5) return { label: '碱性', color: 'bg-blue-500' };
return { label: '强碱性', color: 'bg-purple-500' };
};
export const getOrganicMatterLevel = (om: number) => {
if (om < 10) return { label: '极低', color: 'text-red-600' };
if (om < 20) return { label: '低', color: 'text-orange-600' };
if (om < 30) return { label: '中等', color: 'text-yellow-600' };
if (om < 40) return { label: '较高', color: 'text-green-600' };
return { label: '高', color: 'text-blue-600' };
};

View File

@@ -1,18 +1,26 @@
'use client';
import { useReducer, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { soilDataReducer, initialSoilDataState, SoilDataState } from './components/soilDataReducer';
import SoilDataContent from './components/SoilDataContent';
export default function SoilDataPage() {
const [state, dispatch] = useReducer(soilDataReducer, initialSoilDataState);
useEffect(() => {
// 加载存储的数据
dispatch({ type: 'LOAD_FROM_STORAGE' });
}, []);
// 保存数据到localStorage
useEffect(() => {
dispatch({ type: 'SAVE_TO_STORAGE' });
}, [state.samplePoints]);
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/analysis/soil-data
</p>
</div>
</Card>
<SoilDataContent state={state} dispatch={dispatch} />
</div>
);
}

View File

@@ -0,0 +1,873 @@
/**
* 地块影像组件
* 集成时序遥感影像服务支持天地图、Sentinel、Landsat等数据源
*/
'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 { Slider } from '@/components/ui/slider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Satellite,
Calendar,
Image as ImageIcon,
TrendingUp,
TrendingDown,
Layers,
Download,
Eye,
BarChart3,
Cloud,
Leaf,
Sun,
Droplets,
AlertCircle,
RefreshCw,
Filter,
Maximize2,
ChevronLeft,
ChevronRight,
Activity
} from 'lucide-react';
import { toast } from 'sonner';
import {
SatelliteImageService,
DATA_SOURCES,
getCloudCoverColorClass,
formatImageDate
} from './satelliteImageService';
import {
SatelliteImage,
ImageComparisonResult,
TimeSeriesAnalysis
} from './satelliteTypes';
export function FieldSatellite() {
const [selectedField, setSelectedField] = useState('field-1');
const [imageSource, setImageSource] = useState<string>('Sentinel-2');
const [selectedImage, setSelectedImage] = useState<SatelliteImage | null>(null);
const [comparisonImage, setComparisonImage] = useState<SatelliteImage | null>(null);
const [showComparison, setShowComparison] = useState(false);
const [timeSliderValue, setTimeSliderValue] = useState([0]);
const [maxCloudCover, setMaxCloudCover] = useState(30);
const [images, setImages] = useState<SatelliteImage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [comparisonResult, setComparisonResult] = useState<ImageComparisonResult | null>(null);
const [timeSeriesAnalysis, setTimeSeriesAnalysis] = useState<TimeSeriesAnalysis | null>(null);
const [activeView, setActiveView] = useState<'single' | 'comparison' | 'timeseries'>('single');
// 模拟地块数据
const mockFields = [
{ id: 'field-1', name: '东区1号地', code: 'DB001' },
{ id: 'field-2', name: '西区2号地', code: 'DB002' },
{ id: 'field-3', name: '南区3号地', code: 'DB003' },
];
// 加载影像数据
useEffect(() => {
loadImages();
}, [selectedField, imageSource, maxCloudCover]);
// 自动选择第一张影像
useEffect(() => {
if (images.length > 0 && !selectedImage) {
setSelectedImage(images[0]);
setTimeSliderValue([0]);
}
}, [images]);
// 更新时序分析
useEffect(() => {
if (images.length >= 2) {
const analysis = SatelliteImageService.analyzeTimeSeries(images);
setTimeSeriesAnalysis(analysis);
}
}, [images]);
const loadImages = async () => {
setIsLoading(true);
try {
// 获取最近6个月的影像
const endDate = new Date();
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 6);
const loadedImages = await SatelliteImageService.getFieldImages(
selectedField,
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0],
imageSource,
maxCloudCover
);
setImages(loadedImages);
toast.success(`加载了 ${loadedImages.length} 张影像`);
} catch (error) {
toast.error('加载影像失败');
} finally {
setIsLoading(false);
}
};
const handleImageSelect = (image: SatelliteImage, index: number) => {
setSelectedImage(image);
setTimeSliderValue([index]);
toast.success(`已加载 ${formatImageDate(image.date)} 影像`);
};
const handleTimeSliderChange = (value: number[]) => {
setTimeSliderValue(value);
if (images[value[0]]) {
setSelectedImage(images[value[0]]);
}
};
const handleComparisonToggle = () => {
if (!showComparison) {
// 开启对比模式,选择当前影像的前一张作为对比
const currentIndex = timeSliderValue[0];
if (currentIndex < images.length - 1) {
setComparisonImage(images[currentIndex + 1]);
} else if (images.length >= 2) {
setComparisonImage(images[0]);
}
}
setShowComparison(!showComparison);
setActiveView(showComparison ? 'single' : 'comparison');
};
const handleCompare = () => {
if (!selectedImage || !comparisonImage) {
toast.error('请选择两张影像进行对比');
return;
}
const result = SatelliteImageService.compareImages(comparisonImage, selectedImage);
setComparisonResult(result);
toast.success('影像对比完成');
};
const handleDownload = async () => {
if (!selectedImage) {
toast.error('请先选择影像');
return;
}
try {
await SatelliteImageService.downloadImage(selectedImage, 'jpg');
toast.success(`正在下载 ${formatImageDate(selectedImage.date)} 影像...`);
} catch (error) {
toast.error('下载失败');
}
};
const navigateImage = (direction: 'prev' | 'next') => {
const currentIndex = timeSliderValue[0];
let newIndex = currentIndex;
if (direction === 'prev' && currentIndex > 0) {
newIndex = currentIndex - 1;
} else if (direction === 'next' && currentIndex < images.length - 1) {
newIndex = currentIndex + 1;
}
if (newIndex !== currentIndex) {
handleImageSelect(images[newIndex], newIndex);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800"></h2>
<p className="text-muted-foreground">
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={loadImages}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
{isLoading ? '加载中...' : '刷新影像'}
</Button>
<Button
variant="outline"
onClick={handleComparisonToggle}
>
<BarChart3 className="w-4 h-4 mr-2" />
{showComparison ? '单影像' : '影像对比'}
</Button>
<Button variant="outline" onClick={handleDownload}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 数据源和地块信息 */}
<div className="grid grid-cols-4 gap-4">
<Card className="p-4 bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950">
<div className="flex items-center gap-3">
<Satellite className="w-8 h-8 text-green-600 dark:text-green-400" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{DATA_SOURCES[imageSource as keyof typeof DATA_SOURCES]?.name || imageSource}</div>
</div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="flex items-center gap-3">
<Layers className="w-8 h-8 text-blue-600 dark:text-blue-400" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{images.length} </div>
</div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="flex items-center gap-3">
<Eye className="w-8 h-8 text-purple-600 dark:text-purple-400" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{DATA_SOURCES[imageSource as keyof typeof DATA_SOURCES]?.resolution || '--'}</div>
</div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="flex items-center gap-3">
<Activity className="w-8 h-8 text-orange-600 dark:text-orange-400" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="font-medium">{timeSeriesAnalysis?.healthScore || '--'}</div>
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-4 gap-6">
{/* 左侧控制面板 */}
<div className="space-y-4">
{/* 地块选择 */}
<Card className="p-4 bg-card">
<label className="text-sm font-medium mb-2 block"></label>
<Select value={selectedField} onValueChange={setSelectedField}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{mockFields.map(field => (
<SelectItem key={field.id} value={field.id}>
{field.name} ({field.code})
</SelectItem>
))}
</SelectContent>
</Select>
</Card>
{/* 数据源选择 */}
<Card className="p-4 bg-card">
<label className="text-sm font-medium mb-2 block"></label>
<div className="space-y-2">
{Object.entries(DATA_SOURCES).map(([key, source]) => (
<Button
key={key}
variant={imageSource === key ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => setImageSource(key)}
>
<Satellite className="w-4 h-4 mr-2" />
<div className="text-left flex-1">
<div>{source.name}</div>
<div className="text-xs opacity-70">{source.resolution}m</div>
</div>
</Button>
))}
</div>
</Card>
{/* 云量过滤 */}
<Card className="p-4 bg-card">
<label className="text-sm font-medium mb-2 block">
: {maxCloudCover}%
</label>
<Slider
value={[maxCloudCover]}
onValueChange={(value) => setMaxCloudCover(value[0])}
max={100}
step={5}
className="w-full"
/>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<Cloud className="w-3 h-3" />
<span> {maxCloudCover}% </span>
</div>
</Card>
{/* 视图切换 */}
<Card className="p-4 bg-card">
<label className="text-sm font-medium mb-2 block"></label>
<div className="space-y-2">
<Button
variant={activeView === 'single' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => {
setActiveView('single');
setShowComparison(false);
}}
>
<Eye className="w-4 h-4 mr-2" />
</Button>
<Button
variant={activeView === 'comparison' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => {
setActiveView('comparison');
setShowComparison(true);
}}
>
<BarChart3 className="w-4 h-4 mr-2" />
</Button>
<Button
variant={activeView === 'timeseries' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => setActiveView('timeseries')}
>
<TrendingUp className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
{/* 指标说明 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-3 text-xs">
<div>
<div className="flex items-center gap-2 mb-1">
<Leaf className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="font-medium">NDVI</span>
</div>
<p className="text-muted-foreground">
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-3 h-3 text-blue-600 dark:text-blue-400" />
<span className="font-medium">EVI</span>
</div>
<p className="text-muted-foreground">
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<Activity className="w-3 h-3 text-purple-600 dark:text-purple-400" />
<span className="font-medium">SAVI</span>
</div>
<p className="text-muted-foreground">
</p>
</div>
</div>
</Card>
</div>
{/* 主显示区域 */}
<div className="col-span-3 space-y-4">
<Tabs value={activeView} onValueChange={(v) => setActiveView(v as any)}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="single"></TabsTrigger>
<TabsTrigger value="comparison"></TabsTrigger>
<TabsTrigger value="timeseries"></TabsTrigger>
</TabsList>
{/* 单影像视图 */}
<TabsContent value="single" className="space-y-4">
{/* 影像显示 */}
<Card className="overflow-hidden bg-card">
<div className="relative h-[400px] bg-gradient-to-br from-green-900 via-green-700 to-green-500">
{/* 模拟卫星影像 */}
<div className="absolute inset-0 opacity-60">
<div
className="w-full h-full"
style={{
backgroundImage: `
radial-gradient(circle at 30% 40%, rgba(34, 197, 94, ${selectedImage?.ndvi || 0.8}) 0%, transparent 50%),
radial-gradient(circle at 70% 60%, rgba(22, 163, 74, ${(selectedImage?.ndvi || 0.8) * 0.8}) 0%, transparent 50%),
radial-gradient(circle at 50% 80%, rgba(21, 128, 61, ${(selectedImage?.ndvi || 0.8) * 0.9}) 0%, transparent 40%)
`,
}}
/>
</div>
{/* 导航按钮 */}
<div className="absolute inset-y-0 left-0 right-0 flex items-center justify-between px-4">
<Button
variant="outline"
size="sm"
onClick={() => navigateImage('prev')}
disabled={timeSliderValue[0] === 0}
className="bg-white/90 backdrop-blur dark:bg-black/90"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigateImage('next')}
disabled={timeSliderValue[0] === images.length - 1}
className="bg-white/90 backdrop-blur dark:bg-black/90"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* 影像信息叠加 */}
{selectedImage && (
<>
<div className="absolute top-4 left-4 right-4">
<div className="flex items-center justify-between">
<Card className="px-4 py-2 bg-white/90 backdrop-blur dark:bg-black/90">
<div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="font-medium">{formatImageDate(selectedImage.date)}</span>
<Badge variant="outline" className="font-light">{selectedImage.season}</Badge>
</div>
</Card>
<Card className="px-4 py-2 bg-white/90 backdrop-blur dark:bg-black/90">
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4" />
<span className="text-sm">: {selectedImage.cloudCover.toFixed(0)}%</span>
</div>
</Card>
</div>
</div>
{/* NDVI图例 */}
<div className="absolute bottom-4 left-4">
<Card className="p-3 bg-white/90 backdrop-blur dark:bg-black/90">
<div className="text-xs mb-2">NDVI </div>
<div className="flex items-center gap-2">
<div className="w-32 h-4 rounded" style={{
background: 'linear-gradient(to right, #ef4444, #f97316, #eab308, #84cc16, #22c55e)',
}}></div>
<div className="flex justify-between w-32 text-xs">
<span>0.0</span>
<span>1.0</span>
</div>
</div>
</Card>
</div>
{/* 当前NDVI值 */}
<div className="absolute bottom-4 right-4">
<Card className="p-4 bg-white/90 backdrop-blur dark:bg-black/90">
<div className="text-center">
<div className="text-xs text-muted-foreground mb-1">NDVI</div>
<div
className="text-2xl font-bold"
style={{ color: SatelliteImageService.getNDVIColor(selectedImage.ndvi) }}
>
{selectedImage.ndvi.toFixed(2)}
</div>
<div className="text-xs mt-1">
{SatelliteImageService.getNDVILabel(selectedImage.ndvi)}
</div>
</div>
</Card>
</div>
</>
)}
</div>
</Card>
{/* 作物长势分析 */}
{selectedImage && (
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-4 gap-4">
<Card className="p-4 bg-green-50 dark:bg-green-950">
<div className="flex items-center gap-2 mb-2">
<Leaf className="w-5 h-5 text-green-600 dark:text-green-400" />
<span className="text-sm"></span>
</div>
<div className="text-2xl text-green-600 dark:text-green-400">
{(selectedImage.ndvi * 100).toFixed(0)}%
</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-950">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<span className="text-sm"></span>
</div>
<div className="text-sm text-blue-600 dark:text-blue-400">
{SatelliteImageService.getNDVILabel(selectedImage.ndvi)}
</div>
</Card>
<Card className="p-4 bg-yellow-50 dark:bg-yellow-950">
<div className="flex items-center gap-2 mb-2">
<Sun className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm"></span>
</div>
<div className="text-2xl text-yellow-600 dark:text-yellow-400">{selectedImage.lai.toFixed(1)}</div>
</Card>
<Card className="p-4 bg-cyan-50 dark:bg-cyan-950">
<div className="flex items-center gap-2 mb-2">
<Droplets className="w-5 h-5 text-cyan-600 dark:text-cyan-400" />
<span className="text-sm"></span>
</div>
<div className="text-sm text-cyan-600 dark:text-cyan-400">{selectedImage.evi.toFixed(2)}</div>
</Card>
</div>
</Card>
)}
</TabsContent>
{/* 影像对比视图 */}
<TabsContent value="comparison" className="space-y-4">
<Card className="p-6 bg-card">
<div className="flex items-center justify-between mb-4">
<h3></h3>
<Button onClick={handleCompare}>
<BarChart3 className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 选择对比影像 */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<label className="text-sm font-medium mb-2 block">1</label>
<Select
value={comparisonImage?.id || ''}
onValueChange={(id) => {
const img = images.find(i => i.id === id);
if (img) setComparisonImage(img);
}}
>
<SelectTrigger>
<SelectValue placeholder="选择影像" />
</SelectTrigger>
<SelectContent>
{images.map((img) => (
<SelectItem key={img.id} value={img.id}>
{formatImageDate(img.date)} - NDVI: {img.ndvi.toFixed(2)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-2 block">2</label>
<Select
value={selectedImage?.id || ''}
onValueChange={(id) => {
const img = images.find(i => i.id === id);
if (img) {
setSelectedImage(img);
const index = images.indexOf(img);
setTimeSliderValue([index]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="选择影像" />
</SelectTrigger>
<SelectContent>
{images.map((img) => (
<SelectItem key={img.id} value={img.id}>
{formatImageDate(img.date)} - NDVI: {img.ndvi.toFixed(2)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 对比结果 */}
{comparisonResult && (
<div className="space-y-4">
<Card className={`p-4 ${
comparisonResult.changeType === 'improvement' ? 'bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800' :
comparisonResult.changeType === 'decline' ? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800' :
'bg-gray-50 dark:bg-gray-950'
}`}>
<div className="flex items-center gap-3">
{comparisonResult.changeType === 'improvement' && <TrendingUp className="w-6 h-6 text-green-600 dark:text-green-400" />}
{comparisonResult.changeType === 'decline' && <TrendingDown className="w-6 h-6 text-red-600 dark:text-red-400" />}
{comparisonResult.changeType === 'stable' && <Activity className="w-6 h-6 text-gray-600 dark:text-gray-400" />}
<div className="flex-1">
<h4 className={
comparisonResult.changeType === 'improvement' ? 'text-green-900 dark:text-green-100' :
comparisonResult.changeType === 'decline' ? 'text-red-900 dark:text-red-100' :
'text-gray-900 dark:text-gray-100'
}>
{comparisonResult.changeDescription}
</h4>
</div>
</div>
</Card>
<div className="grid grid-cols-2 gap-4">
<Card className="p-4 bg-card">
<h4 className="mb-3">NDVI变化</h4>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold" style={{
color: comparisonResult.ndviChange > 0 ? '#22c55e' :
comparisonResult.ndviChange < 0 ? '#ef4444' : '#6b7280'
}}>
{comparisonResult.ndviChange > 0 ? '+' : ''}{comparisonResult.ndviChange.toFixed(3)}
</span>
<span className="text-sm text-muted-foreground">
({comparisonResult.image1.ndvi.toFixed(2)} {comparisonResult.image2.ndvi.toFixed(2)})
</span>
</div>
</Card>
<Card className="p-4 bg-card">
<h4 className="mb-3">EVI变化</h4>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold" style={{
color: comparisonResult.eviChange > 0 ? '#22c55e' :
comparisonResult.eviChange < 0 ? '#ef4444' : '#6b7280'
}}>
{comparisonResult.eviChange > 0 ? '+' : ''}{comparisonResult.eviChange.toFixed(3)}
</span>
<span className="text-sm text-muted-foreground">
({comparisonResult.image1.evi.toFixed(2)} {comparisonResult.image2.evi.toFixed(2)})
</span>
</div>
</Card>
</div>
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<ul className="space-y-2">
{comparisonResult.recommendations.map((rec, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<span className="text-green-600 dark:text-green-400 mt-0.5"></span>
<span>{rec}</span>
</li>
))}
</ul>
</Card>
</div>
)}
</Card>
</TabsContent>
{/* 时序分析视图 */}
<TabsContent value="timeseries" className="space-y-4">
{timeSeriesAnalysis && (
<>
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-4 gap-4 mb-6">
<Card className="p-4 bg-blue-50 dark:bg-blue-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="flex items-center gap-2">
{timeSeriesAnalysis.trend === 'increasing' && <TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400" />}
{timeSeriesAnalysis.trend === 'decreasing' && <TrendingDown className="w-5 h-5 text-red-600 dark:text-red-400" />}
{timeSeriesAnalysis.trend === 'stable' && <Activity className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
{timeSeriesAnalysis.trend === 'fluctuating' && <Activity className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />}
<span className="font-medium">
{timeSeriesAnalysis.trend === 'increasing' && '上升'}
{timeSeriesAnalysis.trend === 'decreasing' && '下降'}
{timeSeriesAnalysis.trend === 'stable' && '稳定'}
{timeSeriesAnalysis.trend === 'fluctuating' && '波动'}
</span>
</div>
</Card>
<Card className="p-4 bg-green-50 dark:bg-green-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-lg text-green-600 dark:text-green-400">{timeSeriesAnalysis.growthStage}</div>
</Card>
<Card className="p-4 bg-purple-50 dark:bg-purple-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-purple-600 dark:text-purple-400">{timeSeriesAnalysis.healthScore}</div>
</Card>
<Card className="p-4 bg-orange-50 dark:bg-orange-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-orange-600 dark:text-orange-400">{timeSeriesAnalysis.dates.length}</div>
</Card>
</div>
{/* NDVI趋势图 */}
<div className="relative h-64 border rounded-lg p-4 bg-gray-50 dark:bg-gray-900">
<h4 className="text-sm mb-4">NDVI变化曲线</h4>
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
{/* 网格线 */}
{[0, 25, 50, 75, 100].map(y => (
<line
key={`grid-${y}`}
x1="0"
y1={y}
x2="100"
y2={y}
stroke="#e5e7eb"
strokeWidth="0.2"
/>
))}
{/* NDVI曲线 */}
<polyline
points={timeSeriesAnalysis.ndviValues.map((ndvi, i) =>
`${(i / (timeSeriesAnalysis.ndviValues.length - 1)) * 100},${100 - ndvi * 100}`
).join(' ')}
fill="none"
stroke="#22c55e"
strokeWidth="1"
/>
{/* 数据点 */}
{timeSeriesAnalysis.ndviValues.map((ndvi, i) => (
<circle
key={`point-${i}`}
cx={(i / (timeSeriesAnalysis.ndviValues.length - 1)) * 100}
cy={100 - ndvi * 100}
r="1.5"
fill="#22c55e"
/>
))}
</svg>
{/* 刻度标签 */}
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>{timeSeriesAnalysis.dates[0]}</span>
<span>{timeSeriesAnalysis.dates[timeSeriesAnalysis.dates.length - 1]}</span>
</div>
</div>
</Card>
{/* 警报信息 */}
{timeSeriesAnalysis.alerts.length > 0 && (
<Card className="p-4 bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800">
<h4 className="mb-3 flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</h4>
<ul className="space-y-2">
{timeSeriesAnalysis.alerts.map((alert, index) => (
<li key={index} className="text-sm text-amber-800 dark:text-amber-200">
{alert}
</li>
))}
</ul>
</Card>
)}
</>
)}
</TabsContent>
</Tabs>
{/* 时间滑块(所有视图通用) */}
{images.length > 0 && (
<Card className="p-4 bg-card">
<div className="flex items-center gap-4">
<Calendar className="w-5 h-5 text-green-600 dark:text-green-400" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground">
{images[timeSliderValue[0]] && formatImageDate(images[timeSliderValue[0]].date)}
</span>
</div>
<Slider
value={timeSliderValue}
onValueChange={handleTimeSliderChange}
max={images.length - 1}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-2">
<span>{images[0] && formatImageDate(images[0].date)}</span>
<span>{images[images.length - 1] && formatImageDate(images[images.length - 1].date)}</span>
</div>
</div>
</div>
</Card>
)}
{/* 影像列表 */}
<Card className="p-4 bg-card">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-3 gap-3">
{images.map((image, index) => (
<div
key={image.id}
className={`p-3 border rounded-lg cursor-pointer transition-all ${
selectedImage?.id === image.id
? 'border-green-500 bg-green-50 dark:bg-green-950'
: 'border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700'
}`}
onClick={() => handleImageSelect(image, index)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium">{formatImageDate(image.date)}</span>
</div>
<Badge className={`font-light ${getCloudCoverColorClass(image.cloudCover)}`}>
<Cloud className="w-3 h-3 mr-1" />
{image.cloudCover.toFixed(0)}%
</Badge>
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>{image.source}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span>{image.resolution}m</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">NDVI:</span>
<span style={{ color: SatelliteImageService.getNDVIColor(image.ndvi) }}>
{image.ndvi.toFixed(2)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">EVI:</span>
<span>{image.evi.toFixed(2)}</span>
</div>
</div>
</div>
))}
</div>
</Card>
</div>
</div>
{/* 使用说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<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></li>
<li> <strong></strong>NDVIEVI变化并生成管理建议</li>
<li> <strong></strong>NDVI变化趋势</li>
<li> <strong></strong>Sentinel-210Landsat-830</li>
<li> <strong></strong></li>
<li> <strong></strong>NDVIEVISAVILAI</li>
<li> <strong></strong></li>
</ul>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,352 @@
/**
* 卫星影像服务类
* 提供卫星影像数据的获取、分析和对比功能
*/
import { SatelliteImage, ImageComparisonResult, TimeSeriesAnalysis, DataSource } from './satelliteTypes';
export class SatelliteImageService {
/**
* 获取指定地块的卫星影像数据
*/
static async getFieldImages(
fieldId: string,
startDate: string,
endDate: string,
source: string = 'Sentinel-2',
maxCloudCover: number = 30
): Promise<SatelliteImage[]> {
// 模拟API调用生成测试数据
const mockImages: SatelliteImage[] = [];
const start = new Date(startDate);
const end = new Date(endDate);
// 生成6个月的影像数据每15天一张
const intervalDays = 15;
const totalImages = Math.floor((end.getTime() - start.getTime()) / (intervalDays * 24 * 60 * 60 * 1000));
for (let i = 0; i < totalImages; i++) {
const date = new Date(start);
date.setDate(date.getDate() + i * intervalDays);
// 模拟NDVI变化趋势作物生长周期
const dayOfYear = Math.floor((date.getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / (24 * 60 * 60 * 1000));
const ndviBase = 0.3 + 0.4 * Math.sin((dayOfYear - 90) * Math.PI / 180);
const ndvi = Math.max(0.1, Math.min(0.9, ndviBase + (Math.random() - 0.5) * 0.2));
// 相关植被指数计算
const evi = ndvi * (0.9 + Math.random() * 0.2);
const lai = ndvi * 6 + Math.random() * 2;
// 随机云量
const cloudCover = Math.random() * 50;
// 只包含云量符合要求的影像
if (cloudCover <= maxCloudCover) {
mockImages.push({
id: `${fieldId}-${source}-${date.toISOString().split('T')[0]}`,
date: date.toISOString().split('T')[0],
source,
resolution: parseInt(DATA_SOURCES[source]?.resolution || '10'),
cloudCover,
ndvi,
evi,
lai,
season: SatelliteImageService.getSeason(date),
thumbnail: SatelliteImageService.generateThumbnailUrl(date, source),
url: SatelliteImageService.generateImageUrl(date, source)
});
}
}
return mockImages.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
/**
* 对比两张影像
*/
static compareImages(image1: SatelliteImage, image2: SatelliteImage): ImageComparisonResult {
const ndviChange = image2.ndvi - image1.ndvi;
const eviChange = image2.evi - image1.evi;
let changeType: 'improvement' | 'decline' | 'stable';
let changeDescription: string;
const threshold = 0.05; // NDVI变化阈值
if (ndviChange > threshold) {
changeType = 'improvement';
changeDescription = '作物长势明显改善';
} else if (ndviChange < -threshold) {
changeType = 'decline';
changeDescription = '作物长势出现下降';
} else {
changeType = 'stable';
changeDescription = '作物长势保持稳定';
}
// 生成管理建议
const recommendations = this.generateRecommendations(changeType, ndviChange, eviChange);
return {
image1,
image2,
ndviChange,
eviChange,
changeType,
changeDescription,
recommendations
};
}
/**
* 分析时序数据
*/
static analyzeTimeSeries(images: SatelliteImage[]): TimeSeriesAnalysis {
if (images.length === 0) {
return {
dates: [],
ndviValues: [],
trend: 'stable',
growthStage: '无数据',
healthScore: '--',
alerts: []
};
}
// 按日期排序
const sortedImages = images.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const dates = sortedImages.map(img => img.date);
const ndviValues = sortedImages.map(img => img.ndvi);
// 分析趋势
const trend = this.analyzeTrend(ndviValues);
// 判断生长阶段
const growthStage = this.determineGrowthStage(ndviValues);
// 计算健康分数
const healthScore = this.calculateHealthScore(ndviValues);
// 生成警报
const alerts = this.generateAlerts(ndviValues, sortedImages);
return {
dates,
ndviValues,
trend,
growthStage,
healthScore,
alerts
};
}
/**
* 下载影像
*/
static async downloadImage(image: SatelliteImage, format: 'jpg' | 'png' | 'tiff' = 'jpg'): Promise<void> {
// 模拟下载过程
return new Promise((resolve, reject) => {
setTimeout(() => {
// 模拟下载成功
console.log(`Downloading ${image.url} as ${format}`);
resolve();
}, 2000);
});
}
/**
* 获取NDVI对应的颜色
*/
static getNDVIColor(ndvi: number): string {
if (ndvi < 0.2) return '#ef4444'; // 红色
if (ndvi < 0.4) return '#f97316'; // 橙色
if (ndvi < 0.6) return '#eab308'; // 黄色
if (ndvi < 0.8) return '#84cc16'; // 浅绿色
return '#22c55e'; // 深绿色
}
/**
* 获取NDVI对应的描述
*/
static getNDVILabel(ndvi: number): string {
if (ndvi < 0.2) return '植被稀疏';
if (ndvi < 0.4) return '植被较少';
if (ndvi < 0.6) return '植被适中';
if (ndvi < 0.8) return '植被良好';
return '植被茂盛';
}
// 私有方法
private static getSeason(date: Date): string {
const month = date.getMonth();
if (month >= 2 && month <= 4) return '春季';
if (month >= 5 && month <= 7) return '夏季';
if (month >= 8 && month <= 10) return '秋季';
return '冬季';
}
private static generateThumbnailUrl(date: Date, source: string): string {
const dateStr = date.toISOString().split('T')[0];
return `https://picsum.photos/seed/${source}-${dateStr}/200/200.jpg`;
}
private static generateImageUrl(date: Date, source: string): string {
const dateStr = date.toISOString().split('T')[0];
return `https://picsum.photos/seed/${source}-${dateStr}/800/600.jpg`;
}
private static analyzeTrend(values: number[]): 'increasing' | 'decreasing' | 'stable' | 'fluctuating' {
if (values.length < 2) return 'stable';
let increaseCount = 0;
let decreaseCount = 0;
for (let i = 1; i < values.length; i++) {
if (values[i] > values[i - 1] + 0.02) increaseCount++;
else if (values[i] < values[i - 1] - 0.02) decreaseCount++;
}
const totalChanges = values.length - 1;
const increaseRatio = increaseCount / totalChanges;
const decreaseRatio = decreaseCount / totalChanges;
if (increaseRatio > 0.6) return 'increasing';
if (decreaseRatio > 0.6) return 'decreasing';
if (increaseRatio > 0.3 && decreaseRatio > 0.3) return 'fluctuating';
return 'stable';
}
private static determineGrowthStage(ndviValues: number[]): string {
if (ndviValues.length === 0) return '未知';
const avgNdvi = ndviValues.reduce((sum, val) => sum + val, 0) / ndviValues.length;
const latestNdvi = ndviValues[ndviValues.length - 1];
if (avgNdvi < 0.3) return '苗期';
if (avgNdvi < 0.5) return '生长期';
if (avgNdvi < 0.7) return '旺盛期';
return '成熟期';
}
private static calculateHealthScore(ndviValues: number[]): string {
if (ndviValues.length === 0) return '--';
const avgNdvi = ndviValues.reduce((sum, val) => sum + val, 0) / ndviValues.length;
const latestNdvi = ndviValues[ndviValues.length - 1];
// 综合平均NDVI和最新NDVI计算健康分数
const healthScore = Math.round((avgNdvi * 0.6 + latestNdvi * 0.4) * 100);
return `${healthScore}/100`;
}
private static generateAlerts(ndviValues: number[], images: SatelliteImage[]): string[] {
const alerts: string[] = [];
if (ndviValues.length < 2) return alerts;
const latestNdvi = ndviValues[ndviValues.length - 1];
const previousNdvi = ndviValues[ndviValues.length - 2];
const ndviChange = latestNdvi - previousNdvi;
// NDVI下降警报
if (ndviChange < -0.1) {
alerts.push('NDVI显著下降建议检查灌溉和病虫害情况');
}
// NDVI过低警报
if (latestNdvi < 0.3) {
alerts.push('植被覆盖度过低,建议加强田间管理');
}
// 云量过高警报
const highCloudImages = images.filter(img => img.cloudCover > 40);
if (highCloudImages.length > images.length * 0.5) {
alerts.push('近期影像云量较高,可能影响分析准确性');
}
return alerts;
}
private static generateRecommendations(
changeType: 'improvement' | 'decline' | 'stable',
ndviChange: number,
eviChange: number
): string[] {
const recommendations: string[] = [];
if (changeType === 'improvement') {
recommendations.push('作物长势良好,继续保持当前管理措施');
recommendations.push('可适当增加施肥量,维持作物生长势头');
if (Math.abs(eviChange) > 0.05) {
recommendations.push('EVI指数变化明显建议监测叶面积指数变化');
}
} else if (changeType === 'decline') {
recommendations.push('作物长势下降,建议立即检查灌溉情况');
recommendations.push('加强病虫害监测,必要时采取防治措施');
recommendations.push('考虑补充施肥,提供充足营养');
if (ndviChange < -0.1) {
recommendations.push('NDVI下降明显建议进行实地勘察');
}
} else {
recommendations.push('作物长势稳定,维持当前管理方案');
recommendations.push('定期监测植被指数变化趋势');
recommendations.push('根据生长阶段调整田间管理措施');
}
return recommendations;
}
}
// 导出数据源
export const DATA_SOURCES: Record<string, DataSource> = {
'Sentinel-2': {
name: 'Sentinel-2',
resolution: '10',
description: '欧洲航天局,高分辨率多光谱成像',
provider: 'ESA'
},
'Landsat-8': {
name: 'Landsat-8',
resolution: '30',
description: '美国地质调查局,中分辨率多光谱成像',
provider: 'USGS'
},
'MODIS': {
name: 'MODIS',
resolution: '250',
description: '中分辨率成像光谱仪,每日覆盖',
provider: 'NASA'
},
'高分一号': {
name: '高分一号',
resolution: '8',
description: '中国高分系列,高分辨率光学卫星',
provider: 'CNSA'
},
'天地图': {
name: '天地图',
resolution: '2',
description: '中国国产卫星影像服务',
provider: 'NASG'
}
};
// 工具函数
export const formatImageDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
export const getCloudCoverColorClass = (cloudCover: number): string => {
if (cloudCover <= 10) return 'bg-green-100 text-green-800 border-green-200';
if (cloudCover <= 30) return 'bg-yellow-100 text-yellow-800 border-yellow-200';
if (cloudCover <= 50) return 'bg-orange-100 text-orange-800 border-orange-200';
return 'bg-red-100 text-red-800 border-red-200';
};

View File

@@ -0,0 +1,76 @@
/**
* 卫星影像服务类型定义
*/
export interface SatelliteImage {
id: string;
date: string;
source: string;
resolution: number;
cloudCover: number;
ndvi: number;
evi: number;
lai: number;
season: string;
thumbnail: string;
url: string;
}
export interface ImageComparisonResult {
image1: SatelliteImage;
image2: SatelliteImage;
ndviChange: number;
eviChange: number;
changeType: 'improvement' | 'decline' | 'stable';
changeDescription: string;
recommendations: string[];
}
export interface TimeSeriesAnalysis {
dates: string[];
ndviValues: number[];
trend: 'increasing' | 'decreasing' | 'stable' | 'fluctuating';
growthStage: string;
healthScore: string;
alerts: string[];
}
export interface DataSource {
name: string;
resolution: string;
description: string;
provider: string;
}
export const DATA_SOURCES: Record<string, DataSource> = {
'Sentinel-2': {
name: 'Sentinel-2',
resolution: '10',
description: '欧洲航天局,高分辨率多光谱成像',
provider: 'ESA'
},
'Landsat-8': {
name: 'Landsat-8',
resolution: '30',
description: '美国地质调查局,中分辨率多光谱成像',
provider: 'USGS'
},
'MODIS': {
name: 'MODIS',
resolution: '250',
description: '中分辨率成像光谱仪,每日覆盖',
provider: 'NASA'
},
'高分一号': {
name: '高分一号',
resolution: '8',
description: '中国高分系列,高分辨率光学卫星',
provider: 'CNSA'
},
'天地图': {
name: '天地图',
resolution: '2',
description: '中国国产卫星影像服务',
provider: 'NASG'
}
};

View File

@@ -1,18 +1,7 @@
'use client';
import { Card } from '@/components/ui/card';
import { FieldSatellite } from './components/FieldSatellite';
export default function SatellitePage() {
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/map/satellite
</p>
</div>
</Card>
</div>
);
return <FieldSatellite />;
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Book, Menu, Sunset, Trees, Zap } from "lucide-react";
import { Tractor, Map, Clipboard, Package, Brain, Droplets, Settings } from 'lucide-react';
import { Sprout, Map, Clipboard, Package, Brain, Droplets, Settings } from 'lucide-react';
import { MessageBell } from './components/MessageBell';
import { UserProfile } from './components/UserProfile';
import { ThemeToggle } from './ThemeToggle';
@@ -117,11 +117,11 @@ const Navbar1 = ({ navbarData }: Navbar1Props) => {
<span className="flex items-center gap-2">
<div className="flex items-center gap-3 flex-shrink-0">
<div className="w-10 h-10 bg-green-600 rounded-lg flex items-center justify-center">
<Tractor className="w-6 h-6 text-white" />
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-colors">
<Sprout className="w-6 h-6 text-primary-foreground" />
</div>
<div>
<h1 className="text-green-800"></h1>
<h1 className="text-primary transition-colors" style={{ color: 'var(--primary)' }}></h1>
<p className="text-xs text-muted-foreground">Smart Agriculture Management System</p>
</div>
</div>