生产管理系统前端 - 地块管理三个页面开发:1.地块影像,土壤基础数据,分层采样分析
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,107 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useReducer } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Card } from '@/components/ui/card';
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="p-6">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">分层采样分析</h2>
|
<div>
|
||||||
<div className="p-3 bg-muted rounded-lg mt-3">
|
<h2 className="text-green-800 dark:text-green-200">分层采样分析</h2>
|
||||||
<p className="text-sm">
|
<p className="text-muted-foreground">
|
||||||
<strong>页面路径:</strong> /land-information/analysis/layer-sampling
|
三维可视化展示土壤养分在垂直和水平方向的分布规律
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 < 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 > 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>强酸性 (<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>强碱性 (>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>极低 (<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>高 (>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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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-20cm、20-40cm、40-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
};
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useReducer, useEffect } from 'react';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { soilDataReducer, initialSoilDataState, SoilDataState } from './components/soilDataReducer';
|
||||||
|
import SoilDataContent from './components/SoilDataContent';
|
||||||
|
|
||||||
export default function SoilDataPage() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="p-6">
|
<SoilDataContent state={state} dispatch={dispatch} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>:选择两个时期的影像进行对比,自动计算NDVI、EVI变化并生成管理建议</li>
|
||||||
|
<li>• <strong>时序分析</strong>:分析NDVI变化趋势,判断作物生长阶段,计算健康分数</li>
|
||||||
|
<li>• <strong>多数据源</strong>:支持Sentinel-2(10米)、Landsat-8(30米)、天地图等多种数据源</li>
|
||||||
|
<li>• <strong>云量过滤</strong>:可设置最大云量阈值,自动过滤云量过高的影像,确保分析准确性</li>
|
||||||
|
<li>• <strong>植被指数</strong>:NDVI(植被覆盖度)、EVI(高生物量敏感)、SAVI(土壤调节)、LAI(叶面积指数)</li>
|
||||||
|
<li>• <strong>智能建议</strong>:根据影像变化自动生成灌溉、施肥、病虫害防治等管理建议</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,18 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card } from '@/components/ui/card';
|
import { FieldSatellite } from './components/FieldSatellite';
|
||||||
|
|
||||||
export default function SatellitePage() {
|
export default function SatellitePage() {
|
||||||
return (
|
return <FieldSatellite />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Book, Menu, Sunset, Trees, Zap } from "lucide-react";
|
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 { MessageBell } from './components/MessageBell';
|
||||||
import { UserProfile } from './components/UserProfile';
|
import { UserProfile } from './components/UserProfile';
|
||||||
import { ThemeToggle } from './ThemeToggle';
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
@@ -117,11 +117,11 @@ const Navbar1 = ({ navbarData }: Navbar1Props) => {
|
|||||||
|
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
<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">
|
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center transition-colors">
|
||||||
<Tractor className="w-6 h-6 text-white" />
|
<Sprout className="w-6 h-6 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-xs text-muted-foreground">Smart Agriculture Management System</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user