Compare commits

...

2 Commits

73 changed files with 16060 additions and 124 deletions

View File

@@ -0,0 +1,862 @@
/**
* 土壤质量评价组件
* 提供土壤质量分析、评价和改良建议功能
*/
'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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import {
BarChart3,
TrendingUp,
TrendingDown,
Activity,
AlertCircle,
CheckCircle,
Info,
Droplets,
Leaf,
Zap,
Beaker,
Calculator,
FileText,
Calendar,
MapPin,
User,
Download,
RefreshCw,
Eye,
Settings,
Target,
Award,
AlertTriangle,
ThumbsUp
} from 'lucide-react';
import { toast } from 'sonner';
import {
SoilQualityService,
SoilQualityEvaluation,
SoilIndicator,
SoilRecommendation,
SoilQualityHistory,
SoilAnalysisForm,
formatSoilScore,
getSoilGradeColor,
getIndicatorStatusColor,
formatDate
} from './soilQualityService';
import {
SOIL_TYPES,
SOIL_TEXTURES,
DRAINAGE_LEVELS
} from './soilTypes';
export function SoilQualityAnalysis() {
const [activeTab, setActiveTab] = useState<'analysis' | 'evaluation' | 'history' | 'recommendations'>('analysis');
const [selectedField, setSelectedField] = useState('field-1');
const [isLoading, setIsLoading] = useState(false);
const [currentEvaluation, setCurrentEvaluation] = useState<SoilQualityEvaluation | null>(null);
const [historicalData, setHistoricalData] = useState<SoilQualityHistory[]>([]);
const [showForm, setShowForm] = useState(false);
// 表单数据
const [formData, setFormData] = useState<SoilAnalysisForm>({
fieldId: 'field-1',
sampleDate: new Date().toISOString().split('T')[0],
sampleDepth: 20,
ph: 6.5,
organicMatter: 2.5,
nitrogen: 1.2,
phosphorus: 18,
potassium: 120,
calcium: 1200,
magnesium: 250,
sulfur: 15,
iron: 10,
manganese: 5,
zinc: 2,
copper: 1,
boron: 0.8,
soilTexture: 'medium',
drainage: 'good',
notes: ''
});
// 模拟地块数据
const mockFields = [
{ id: 'field-1', name: '东区1号地', code: 'DB001', area: 85.5 },
{ id: 'field-2', name: '西区2号地', code: 'DB002', area: 92.3 },
{ id: 'field-3', name: '南区3号地', code: 'DB003', area: 78.7 },
];
// 初始化数据
useEffect(() => {
loadHistoricalData();
}, [selectedField]);
const loadHistoricalData = () => {
const history = SoilQualityService.getHistoricalData(selectedField);
setHistoricalData(history);
// 如果有历史记录,显示最新的评价
if (history.length > 0) {
setCurrentEvaluation(history[0].evaluation);
}
};
const handleFormSubmit = async () => {
setIsLoading(true);
try {
// 生成土壤质量评价
const evaluation = SoilQualityService.generateEvaluation(formData);
setCurrentEvaluation(evaluation);
// 更新历史数据
const newHistory: SoilQualityHistory = {
date: evaluation.date,
overallScore: evaluation.overallScore,
indicatorChanges: [],
evaluation
};
setHistoricalData([newHistory, ...historicalData]);
setShowForm(false);
setActiveTab('evaluation');
toast.success('土壤质量评价完成!');
} catch (error) {
toast.error('评价失败,请检查数据');
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: keyof SoilAnalysisForm, value: any) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const exportReport = () => {
if (!currentEvaluation) {
toast.error('没有可导出的评价报告');
return;
}
// 模拟导出功能
toast.success('正在生成土壤质量评价报告...');
setTimeout(() => {
toast.success('报告已生成并下载');
}, 2000);
};
const getPriorityColor = (priority: string): string => {
switch (priority) {
case 'high': return 'text-red-600 dark:text-red-400';
case 'medium': return 'text-yellow-600 dark:text-yellow-400';
case 'low': return 'text-green-600 dark:text-green-400';
default: return 'text-gray-600 dark:text-gray-400';
}
};
const getPriorityBadgeVariant = (priority: string): string => {
switch (priority) {
case 'high': return 'destructive';
case 'medium': return 'default';
case 'low': return 'secondary';
default: return 'outline';
}
};
return (
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowForm(!showForm)}>
<Calculator className="w-4 h-4 mr-2" />
{showForm ? '隐藏表单' : '新建评价'}
</Button>
<Button variant="outline" onClick={loadHistoricalData}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={exportReport} disabled={!currentEvaluation}>
<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">
<MapPin 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">{mockFields.find(f => f.id === selectedField)?.name}</div>
</div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="flex items-center gap-3">
<Award className="w-8 h-8 text-blue-600 dark:text-blue-400" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div
className="font-bold text-lg"
style={{ color: currentEvaluation ? getSoilGradeColor(currentEvaluation.overallGrade) : '#6b7280' }}
>
{currentEvaluation ? formatSoilScore(currentEvaluation.overallScore) : '--'}
</div>
</div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="flex items-center gap-3">
<Activity className="w-8 h-8 text-purple-600 dark:text-purple-400" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div
className="font-bold text-lg"
style={{ color: currentEvaluation ? getSoilGradeColor(currentEvaluation.overallGrade) : '#6b7280' }}
>
{currentEvaluation ? currentEvaluation.overallGrade : '--'}
</div>
</div>
</div>
</Card>
<Card className="p-4 bg-card">
<div className="flex items-center gap-3">
<FileText 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">{historicalData.length} </div>
</div>
</div>
</Card>
</div>
{/* 土壤分析表单 */}
{showForm && (
<Card className="p-6 bg-card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold"></h3>
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>
<Eye className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="grid grid-cols-2 gap-6">
{/* 基本信息 */}
<div className="space-y-4">
<h4 className="font-medium"></h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="fieldId"></Label>
<Select value={formData.fieldId} onValueChange={(value) => handleInputChange('fieldId', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{mockFields.map(field => (
<SelectItem key={field.id} value={field.id}>
{field.name} ({field.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sampleDate"></Label>
<Input
id="sampleDate"
type="date"
value={formData.sampleDate}
onChange={(e) => handleInputChange('sampleDate', e.target.value)}
/>
</div>
<div>
<Label htmlFor="sampleDepth"> (cm)</Label>
<Input
id="sampleDepth"
type="number"
value={formData.sampleDepth}
onChange={(e) => handleInputChange('sampleDepth', Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="soilTexture"></Label>
<Select value={formData.soilTexture} onValueChange={(value) => handleInputChange('soilTexture', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOIL_TEXTURES.map(texture => (
<SelectItem key={texture.id} value={texture.id}>
{texture.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="drainage"></Label>
<Select value={formData.drainage} onValueChange={(value) => handleInputChange('drainage', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DRAINAGE_LEVELS.map(level => (
<SelectItem key={level.id} value={level.id}>
{level.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 化学指标 */}
<div className="space-y-4">
<h4 className="font-medium"></h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="ph">pH值</Label>
<Input
id="ph"
type="number"
step="0.1"
value={formData.ph}
onChange={(e) => handleInputChange('ph', Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="organicMatter"> (%)</Label>
<Input
id="organicMatter"
type="number"
step="0.1"
value={formData.organicMatter}
onChange={(e) => handleInputChange('organicMatter', Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="nitrogen"> (g/kg)</Label>
<Input
id="nitrogen"
type="number"
step="0.1"
value={formData.nitrogen}
onChange={(e) => handleInputChange('nitrogen', Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="phosphorus"> (mg/kg)</Label>
<Input
id="phosphorus"
type="number"
step="1"
value={formData.phosphorus}
onChange={(e) => handleInputChange('phosphorus', Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="potassium"> (mg/kg)</Label>
<Input
id="potassium"
type="number"
step="1"
value={formData.potassium}
onChange={(e) => handleInputChange('potassium', Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="notes"></Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => handleInputChange('notes', e.target.value)}
placeholder="请输入其他相关信息..."
/>
</div>
</div>
</div>
</div>
<div className="flex justify-end mt-6">
<Button onClick={handleFormSubmit} disabled={isLoading}>
{isLoading ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Calculator className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</Card>
)}
{/* 主要内容区域 */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="analysis"></TabsTrigger>
<TabsTrigger value="evaluation"></TabsTrigger>
<TabsTrigger value="recommendations"></TabsTrigger>
<TabsTrigger value="history"></TabsTrigger>
</TabsList>
{/* 指标分析 */}
<TabsContent value="analysis" className="space-y-4">
{currentEvaluation ? (
<div className="grid grid-cols-2 gap-6">
{/* 主要指标 */}
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="space-y-4">
{currentEvaluation.indicators
.filter(ind => ['ph', 'organicMatter', 'nitrogen', 'phosphorus', 'potassium'].includes(ind.id))
.map(indicator => (
<div key={indicator.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getIndicatorStatusColor(indicator.status) }}
/>
<div>
<div className="font-medium">{indicator.name}</div>
<div className="text-sm text-muted-foreground">
{indicator.value} {indicator.unit}
</div>
</div>
</div>
<div className="text-right">
<div className="font-bold">{indicator.score}</div>
<Badge
variant="outline"
className="font-light"
style={{
backgroundColor: `${getIndicatorStatusColor(indicator.status)}20`,
borderColor: getIndicatorStatusColor(indicator.status),
color: getIndicatorStatusColor(indicator.status)
}}
>
{indicator.status === 'excellent' && '优秀'}
{indicator.status === 'good' && '良好'}
{indicator.status === 'moderate' && '中等'}
{indicator.status === 'poor' && '较差'}
{indicator.status === 'very_poor' && '很差'}
</Badge>
</div>
</div>
))}
</div>
</Card>
{/* 微量元素 */}
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="space-y-4">
{currentEvaluation.indicators
.filter(ind => ['iron', 'manganese', 'zinc', 'copper', 'boron'].includes(ind.id))
.map(indicator => (
<div key={indicator.id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getIndicatorStatusColor(indicator.status) }}
/>
<div>
<div className="font-medium">{indicator.name}</div>
<div className="text-sm text-muted-foreground">
{indicator.value} {indicator.unit}
</div>
</div>
</div>
<div className="text-right">
<div className="font-bold">{indicator.score}</div>
<Badge
variant="outline"
className="font-light"
style={{
backgroundColor: `${getIndicatorStatusColor(indicator.status)}20`,
borderColor: getIndicatorStatusColor(indicator.status),
color: getIndicatorStatusColor(indicator.status)
}}
>
{indicator.status === 'excellent' && '优秀'}
{indicator.status === 'good' && '良好'}
{indicator.status === 'moderate' && '中等'}
{indicator.status === 'poor' && '较差'}
{indicator.status === 'very_poor' && '很差'}
</Badge>
</div>
</div>
))}
</div>
</Card>
</div>
) : (
<Card className="p-8 text-center bg-card">
<Beaker className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground mb-4">
</p>
<Button onClick={() => setShowForm(true)}>
<Calculator className="w-4 h-4 mr-2" />
</Button>
</Card>
)}
</TabsContent>
{/* 评价结果 */}
<TabsContent value="evaluation" className="space-y-4">
{currentEvaluation ? (
<div className="space-y-4">
{/* 总体评价 */}
<Card className="p-6 bg-card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"></h3>
<div className="flex items-center gap-3">
<div className="text-center">
<div
className="text-4xl font-bold"
style={{ color: getSoilGradeColor(currentEvaluation.overallGrade) }}
>
{currentEvaluation.overallScore}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
<div className="text-center">
<div
className="text-3xl font-bold"
style={{ color: getSoilGradeColor(currentEvaluation.overallGrade) }}
>
{currentEvaluation.overallGrade}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
</div>
<div className="grid grid-cols-3 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="font-medium"></span>
</div>
<div className="text-lg text-green-600 dark:text-green-400">
{currentEvaluation.soilType}
</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-950">
<div className="flex items-center gap-2 mb-2">
<Droplets className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<span className="font-medium"></span>
</div>
<div className="text-lg text-blue-600 dark:text-blue-400">
{currentEvaluation.drainage === 'excellent' && '优秀'}
{currentEvaluation.drainage === 'good' && '良好'}
{currentEvaluation.drainage === 'moderate' && '中等'}
{currentEvaluation.drainage === 'poor' && '较差'}
</div>
</Card>
<Card className="p-4 bg-purple-50 dark:bg-purple-950">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<span className="font-medium"></span>
</div>
<div className="text-lg text-purple-600 dark:text-purple-400">
{currentEvaluation.fertility === 'high' && '高'}
{currentEvaluation.fertility === 'medium' && '中'}
{currentEvaluation.fertility === 'low' && '低'}
</div>
</Card>
</div>
{/* 评价信息 */}
<div className="grid grid-cols-2 gap-4 mt-6">
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span>{formatDate(currentEvaluation.date)}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<User className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span>{currentEvaluation.evaluator}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span>{currentEvaluation.location}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Target className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground"></span>
<span>{formatDate(currentEvaluation.nextEvaluationDate)}</span>
</div>
</div>
{currentEvaluation.evaluationNotes && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-sm">{currentEvaluation.evaluationNotes}</div>
</div>
)}
</Card>
{/* 得分分布 */}
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="space-y-3">
{currentEvaluation.indicators.map(indicator => (
<div key={indicator.id} className="space-y-2">
<div className="flex justify-between text-sm">
<span>{indicator.name}</span>
<span>{indicator.score}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="h-2 rounded-full"
style={{
width: `${indicator.score}%`,
backgroundColor: getIndicatorStatusColor(indicator.status)
}}
/>
</div>
</div>
))}
</div>
</Card>
</div>
) : (
<Card className="p-8 text-center bg-card">
<Award className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground mb-4">
</p>
<Button onClick={() => setShowForm(true)}>
<Calculator className="w-4 h-4 mr-2" />
</Button>
</Card>
)}
</TabsContent>
{/* 改良建议 */}
<TabsContent value="recommendations" className="space-y-4">
{currentEvaluation && currentEvaluation.recommendations.length > 0 ? (
<div className="space-y-4">
{currentEvaluation.recommendations.map((recommendation, index) => (
<Card key={index} className="p-6 bg-card">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{recommendation.priority === 'high' && <AlertTriangle className="w-5 h-5 text-red-500" />}
{recommendation.priority === 'medium' && <AlertCircle className="w-5 h-5 text-yellow-500" />}
{recommendation.priority === 'low' && <Info className="w-5 h-5 text-blue-500" />}
<div>
<h4 className="font-semibold">{recommendation.title}</h4>
<p className="text-sm text-muted-foreground mt-1">{recommendation.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={getPriorityBadgeVariant(recommendation.priority) as any}>
{recommendation.priority === 'high' && '高优先级'}
{recommendation.priority === 'medium' && '中优先级'}
{recommendation.priority === 'low' && '低优先级'}
</Badge>
</div>
</div>
{/* 行动项目 */}
<div className="mb-4">
<h5 className="font-medium mb-2"></h5>
<ul className="space-y-1">
{recommendation.actionItems.map((item, itemIndex) => (
<li key={itemIndex} className="flex items-start gap-2 text-sm">
<CheckCircle className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{/* 预期效果和成本 */}
<div className="grid grid-cols-3 gap-4">
<Card className="p-3 bg-green-50 dark:bg-green-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-bold text-green-600 dark:text-green-400">
+{recommendation.expectedImprovement}
</div>
</Card>
<Card className="p-3 bg-blue-50 dark:bg-blue-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-bold text-blue-600 dark:text-blue-400">
{recommendation.timeframe}
</div>
</Card>
<Card className="p-3 bg-purple-50 dark:bg-purple-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-bold text-purple-600 dark:text-purple-400">
¥{recommendation.estimatedCost}
{recommendation.costUnit === 'yuan_per_mu' && '/亩'}
{recommendation.costUnit === 'yuan_per_hectare' && '/公顷'}
</div>
</Card>
</div>
</Card>
))}
</div>
) : (
<Card className="p-8 text-center bg-card">
<ThumbsUp className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground mb-4">
</p>
<Button variant="outline" onClick={() => setShowForm(true)}>
<Calculator className="w-4 h-4 mr-2" />
</Button>
</Card>
)}
</TabsContent>
{/* 历史记录 */}
<TabsContent value="history" className="space-y-4">
{historicalData.length > 0 ? (
<div className="space-y-4">
{/* 趋势分析 */}
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground"></span>
<span className={`font-medium ${
historicalData[0].overallScore > historicalData[1]?.overallScore ? 'text-green-600' :
historicalData[0].overallScore < historicalData[1]?.overallScore ? 'text-red-600' :
'text-gray-600'
}`}>
{historicalData[0].overallScore > historicalData[1]?.overallScore && '↑ 上升趋势'}
{historicalData[0].overallScore < historicalData[1]?.overallScore && '↓ 下降趋势'}
{historicalData[0].overallScore === historicalData[1]?.overallScore && '→ 保持稳定'}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div
className="h-3 rounded-full bg-gradient-to-r from-blue-500 to-green-500"
style={{ width: `${historicalData[0].overallScore}%` }}
/>
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{historicalData[0].overallScore}
</div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
</Card>
{/* 历史记录列表 */}
<div className="space-y-3">
{historicalData.map((history, index) => (
<Card key={index} className="p-4 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-center">
<div
className="text-2xl font-bold"
style={{ color: getSoilGradeColor(history.evaluation.overallGrade) }}
>
{history.overallScore}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="font-medium">
{formatDate(history.date)} - {history.evaluation.overallGrade}
</div>
<div className="text-sm text-muted-foreground">
{history.evaluation.evaluator}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{index === 0 && (
<Badge variant="default"></Badge>
)}
<Button variant="outline" size="sm">
<Eye className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
) : (
<Card className="p-8 text-center bg-card">
<FileText className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground mb-4">
</p>
<Button onClick={() => setShowForm(true)}>
<Calculator className="w-4 h-4 mr-2" />
</Button>
</Card>
)}
</TabsContent>
</Tabs>
{/* 使用说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<Info className="w-5 h-5 text-blue-600 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>13pH值</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>
</div>
);
}

View File

@@ -0,0 +1,558 @@
/**
* 土壤质量评价服务类
* 提供土壤质量分析、评价和改良建议功能
*/
import {
SoilIndicator,
SoilQualityEvaluation,
SoilRecommendation,
SoilQualityHistory,
SoilAnalysisForm,
FertilizerRecommendation,
SOIL_INDICATORS,
SOIL_QUALITY_GRADES,
SOIL_TYPES,
SOIL_TEXTURES,
DRAINAGE_LEVELS
} from './soilTypes';
export class SoilQualityService {
/**
* 根据土壤分析表单数据生成土壤指标
*/
static generateIndicators(formData: SoilAnalysisForm): SoilIndicator[] {
const indicators: SoilIndicator[] = [];
// 处理每个指标
Object.entries(SOIL_INDICATORS).forEach(([key, config]) => {
let value: number;
switch (key) {
case 'ph':
value = formData.ph;
break;
case 'organicMatter':
value = formData.organicMatter;
break;
case 'nitrogen':
value = formData.nitrogen;
break;
case 'phosphorus':
value = formData.phosphorus;
break;
case 'potassium':
value = formData.potassium;
break;
case 'calcium':
value = formData.calcium;
break;
case 'magnesium':
value = formData.magnesium;
break;
case 'sulfur':
value = formData.sulfur;
break;
case 'iron':
value = formData.iron;
break;
case 'manganese':
value = formData.manganese;
break;
case 'zinc':
value = formData.zinc;
break;
case 'copper':
value = formData.copper;
break;
case 'boron':
value = formData.boron;
break;
default:
return;
}
const score = this.calculateIndicatorScore(value, config.optimalRange);
const status = this.getIndicatorStatus(score);
indicators.push({
id: config.id,
name: config.name,
value,
unit: config.unit,
optimalRange: config.optimalRange,
score,
status,
weight: config.weight,
description: config.description
});
});
return indicators;
}
/**
* 计算单个指标的得分
*/
static calculateIndicatorScore(value: number, optimalRange: { min: number; max: number }): number {
if (value >= optimalRange.min && value <= optimalRange.max) {
// 在最佳范围内,满分
return 100;
}
const midPoint = (optimalRange.min + optimalRange.max) / 2;
const range = optimalRange.max - optimalRange.min;
if (value < optimalRange.min) {
// 低于最佳范围
const deviation = optimalRange.min - value;
if (deviation <= range * 0.5) {
return 100 - (deviation / (range * 0.5)) * 30;
} else {
return Math.max(0, 70 - (deviation - range * 0.5) / range * 70);
}
} else {
// 高于最佳范围
const deviation = value - optimalRange.max;
if (deviation <= range * 0.5) {
return 100 - (deviation / (range * 0.5)) * 30;
} else {
return Math.max(0, 70 - (deviation - range * 0.5) / range * 70);
}
}
}
/**
* 获取指标状态
*/
static getIndicatorStatus(score: number): 'excellent' | 'good' | 'moderate' | 'poor' | 'very_poor' {
if (score >= 90) return 'excellent';
if (score >= 80) return 'good';
if (score >= 70) return 'moderate';
if (score >= 60) return 'poor';
return 'very_poor';
}
/**
* 计算土壤质量总体评分
*/
static calculateOverallScore(indicators: SoilIndicator[]): number {
const weightedSum = indicators.reduce((sum, indicator) => {
return sum + (indicator.score * indicator.weight);
}, 0);
return Math.round(weightedSum);
}
/**
* 获取土壤质量等级
*/
static getSoilQualityGrade(score: number): 'A' | 'B' | 'C' | 'D' | 'E' {
for (const [grade, config] of Object.entries(SOIL_QUALITY_GRADES)) {
if (score >= config.min && score <= config.max) {
return grade as 'A' | 'B' | 'C' | 'D' | 'E';
}
}
return 'E';
}
/**
* 生成土壤质量评价
*/
static generateEvaluation(formData: SoilAnalysisForm): SoilQualityEvaluation {
const indicators = this.generateIndicators(formData);
const overallScore = this.calculateOverallScore(indicators);
const overallGrade = this.getSoilQualityGrade(overallScore);
const recommendations = this.generateRecommendations(indicators, formData);
return {
id: `eval-${Date.now()}`,
fieldId: formData.fieldId,
fieldName: this.getFieldName(formData.fieldId),
date: formData.sampleDate,
location: this.getFieldLocation(formData.fieldId),
indicators,
overallScore,
overallGrade,
soilType: this.determineSoilType(formData.soilTexture),
texture: formData.soilTexture,
drainage: formData.drainage as any,
fertility: this.determineFertility(overallScore),
recommendations,
evaluationNotes: formData.notes || '',
evaluator: '系统自动评价',
nextEvaluationDate: this.calculateNextEvaluationDate(formData.sampleDate)
};
}
/**
* 生成改良建议
*/
static generateRecommendations(indicators: SoilIndicator[], formData: SoilAnalysisForm): SoilRecommendation[] {
const recommendations: SoilRecommendation[] = [];
// 分析pH值
const phIndicator = indicators.find(ind => ind.id === 'ph');
if (phIndicator && (phIndicator.status === 'poor' || phIndicator.status === 'very_poor')) {
if (phIndicator.value < 6.0) {
recommendations.push({
category: 'ph_adjustment',
priority: 'high',
title: '土壤酸化改良',
description: '土壤偏酸性需要进行酸化改良以提高pH值',
actionItems: [
'施用石灰每亩施用量100-200公斤',
'增施有机肥,改善土壤缓冲能力',
'选择耐酸性作物进行轮作',
'减少酸性肥料的使用'
],
expectedImprovement: 15,
timeframe: '3-6个月',
estimatedCost: 200,
costUnit: 'yuan_per_mu'
});
} else if (phIndicator.value > 7.5) {
recommendations.push({
category: 'ph_adjustment',
priority: 'high',
title: '土壤碱化改良',
description: '土壤偏碱性需要进行碱化改良以降低pH值',
actionItems: [
'施用硫磺或硫酸亚铁每亩施用量20-50公斤',
'增施酸性有机肥(如松针堆肥)',
'种植绿肥作物,如三叶草、苜蓿',
'减少碱性肥料的使用'
],
expectedImprovement: 15,
timeframe: '6-12个月',
estimatedCost: 300,
costUnit: 'yuan_per_mu'
});
}
}
// 分析有机质
const omIndicator = indicators.find(ind => ind.id === 'organicMatter');
if (omIndicator && omIndicator.status !== 'excellent') {
recommendations.push({
category: 'organic_matter',
priority: omIndicator.status === 'very_poor' ? 'high' : 'medium',
title: '有机质提升',
description: '土壤有机质含量偏低,需要增施有机肥',
actionItems: [
'施用腐熟农家肥每亩2000-3000公斤',
'种植绿肥作物并翻压还田',
'秸秆还田,增加土壤有机质',
'使用生物有机肥,改善土壤微生物环境'
],
expectedImprovement: 20,
timeframe: '6-12个月',
estimatedCost: 500,
costUnit: 'yuan_per_mu'
});
}
// 分析氮磷钾
const npkIndicators = indicators.filter(ind => ['nitrogen', 'phosphorus', 'potassium'].includes(ind.id));
const deficientNPK = npkIndicators.filter(ind => ind.status === 'poor' || ind.status === 'very_poor');
if (deficientNPK.length > 0) {
const fertilizerRec = this.generateFertilizerRecommendation(deficientNPK);
recommendations.push({
category: 'fertilizer',
priority: 'high',
title: '营养元素补充',
description: `土壤缺乏${deficientNPK.map(ind => ind.name).join('、')},需要针对性施肥`,
actionItems: fertilizerRec.actionItems,
expectedImprovement: 25,
timeframe: '1-3个月',
estimatedCost: fertilizerRec.estimatedCost,
costUnit: 'yuan_per_mu'
});
}
// 分析微量元素
const microIndicators = indicators.filter(ind =>
['iron', 'manganese', 'zinc', 'copper', 'boron'].includes(ind.id) &&
(ind.status === 'poor' || ind.status === 'very_poor')
);
if (microIndicators.length > 0) {
recommendations.push({
category: 'fertilizer',
priority: 'medium',
title: '微量元素补充',
description: `土壤缺乏${microIndicators.map(ind => ind.name).join('、')}等微量元素`,
actionItems: [
'施用复合微量元素肥料',
'叶面喷施微量元素溶液',
'增施有机肥,提高微量元素有效性',
'调节土壤pH值改善微量元素吸收'
],
expectedImprovement: 10,
timeframe: '1-2个月',
estimatedCost: 150,
costUnit: 'yuan_per_mu'
});
}
// 排水改良建议
if (formData.drainage === 'poor' || formData.drainage === 'moderate') {
recommendations.push({
category: 'drainage',
priority: formData.drainage === 'poor' ? 'high' : 'medium',
title: '排水系统改良',
description: '土壤排水条件较差,需要改良排水系统',
actionItems: [
'开挖排水沟,改善地表排水',
'深耕土壤,打破犁底层',
'增施有机肥,改善土壤结构',
'种植深根系作物,改良土壤通透性'
],
expectedImprovement: 15,
timeframe: '3-6个月',
estimatedCost: 800,
costUnit: 'yuan_per_mu'
});
}
return recommendations.sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
});
}
/**
* 生成施肥建议
*/
private static generateFertilizerRecommendation(deficientIndicators: SoilIndicator[]): {
actionItems: string[];
estimatedCost: number;
} {
const actionItems: string[] = [];
let estimatedCost = 0;
deficientIndicators.forEach(indicator => {
switch (indicator.id) {
case 'nitrogen':
actionItems.push('施用尿素每亩20-30公斤');
actionItems.push('或施用复合肥15-15-15每亩40-50公斤');
estimatedCost += 120;
break;
case 'phosphorus':
actionItems.push('施用过磷酸钙每亩30-40公斤');
actionItems.push('或施用磷酸二铵每亩15-20公斤');
estimatedCost += 100;
break;
case 'potassium':
actionItems.push('施用氯化钾每亩10-15公斤');
actionItems.push('或施用硫酸钾每亩12-18公斤');
estimatedCost += 80;
break;
}
});
return { actionItems, estimatedCost };
}
/**
* 确定土壤肥力等级
*/
static determineFertility(score: number): 'high' | 'medium' | 'low' {
if (score >= 80) return 'high';
if (score >= 60) return 'medium';
return 'low';
}
/**
* 确定土壤类型
*/
static determineSoilType(texture: string): string {
const soilType = SOIL_TYPES.find(type => type.id === texture);
return soilType ? soilType.name : '未知';
}
/**
* 获取地块名称
*/
private static getFieldName(fieldId: string): string {
const mockFields = {
'field-1': '东区1号地',
'field-2': '西区2号地',
'field-3': '南区3号地'
};
return mockFields[fieldId as keyof typeof mockFields] || '未知地块';
}
/**
* 获取地块位置
*/
private static getFieldLocation(fieldId: string): string {
const mockLocations = {
'field-1': '东区A区',
'field-2': '西区B区',
'field-3': '南区C区'
};
return mockLocations[fieldId as keyof typeof mockLocations] || '未知位置';
}
/**
* 计算下次评价日期
*/
private static calculateNextEvaluationDate(currentDate: string): string {
const date = new Date(currentDate);
date.setFullYear(date.getFullYear() + 1);
return date.toISOString().split('T')[0];
}
/**
* 获取历史记录数据
*/
static getHistoricalData(fieldId: string): SoilQualityHistory[] {
// 模拟历史数据
const mockHistory: SoilQualityHistory[] = [
{
date: '2024-01-15',
overallScore: 75,
indicatorChanges: [],
evaluation: {
id: 'eval-2024-01',
fieldId,
fieldName: this.getFieldName(fieldId),
date: '2024-01-15',
location: this.getFieldLocation(fieldId),
indicators: [],
overallScore: 75,
overallGrade: 'C',
soilType: '壤土',
texture: 'medium',
drainage: 'good',
fertility: 'medium',
recommendations: [],
evaluationNotes: '春季施肥前取样分析',
evaluator: '技术员张三',
nextEvaluationDate: '2025-01-15'
}
},
{
date: '2023-07-20',
overallScore: 72,
indicatorChanges: [],
evaluation: {
id: 'eval-2023-07',
fieldId,
fieldName: this.getFieldName(fieldId),
date: '2023-07-20',
location: this.getFieldLocation(fieldId),
indicators: [],
overallScore: 72,
overallGrade: 'C',
soilType: '壤土',
texture: 'medium',
drainage: 'good',
fertility: 'medium',
recommendations: [],
evaluationNotes: '夏季作物生长期取样',
evaluator: '技术员李四',
nextEvaluationDate: '2024-07-20'
}
},
{
date: '2023-01-10',
overallScore: 68,
indicatorChanges: [],
evaluation: {
id: 'eval-2023-01',
fieldId,
fieldName: this.getFieldName(fieldId),
date: '2023-01-10',
location: this.getFieldLocation(fieldId),
indicators: [],
overallScore: 68,
overallGrade: 'D',
soilType: '壤土',
texture: 'medium',
drainage: 'moderate',
fertility: 'low',
recommendations: [],
evaluationNotes: '年初土壤基础检测',
evaluator: '技术员王五',
nextEvaluationDate: '2024-01-10'
}
}
];
return mockHistory;
}
/**
* 分析指标变化趋势
*/
static analyzeTrend(historicalData: SoilQualityHistory[]): {
trend: 'improving' | 'stable' | 'declining';
changeRate: number;
description: string;
} {
if (historicalData.length < 2) {
return {
trend: 'stable',
changeRate: 0,
description: '数据不足,无法分析趋势'
};
}
const latest = historicalData[0];
const previous = historicalData[1];
const change = latest.overallScore - previous.overallScore;
let trend: 'improving' | 'stable' | 'declining';
let description: string;
if (Math.abs(change) < 3) {
trend = 'stable';
description = '土壤质量保持稳定';
} else if (change > 0) {
trend = 'improving';
description = `土壤质量正在改善,提升了${change}`;
} else {
trend = 'declining';
description = `土壤质量有所下降,降低了${Math.abs(change)}`;
}
return {
trend,
changeRate: change,
description
};
}
}
// 工具函数
export const formatSoilScore = (score: number): string => {
return `${score}`;
};
export const getSoilGradeColor = (grade: string): string => {
const gradeConfig = SOIL_QUALITY_GRADES[grade as keyof typeof SOIL_QUALITY_GRADES];
return gradeConfig?.color || '#6b7280';
};
export const getIndicatorStatusColor = (status: string): string => {
const colorMap = {
excellent: '#22c55e',
good: '#84cc16',
moderate: '#eab308',
poor: '#f97316',
very_poor: '#ef4444'
};
return colorMap[status as keyof typeof colorMap] || '#6b7280';
};
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};

View File

@@ -0,0 +1,256 @@
/**
* 土壤质量评价类型定义
*/
export interface SoilIndicator {
id: string;
name: string;
value: number;
unit: string;
optimalRange: {
min: number;
max: number;
};
score: number;
status: 'excellent' | 'good' | 'moderate' | 'poor' | 'very_poor';
weight: number;
description: string;
}
export interface SoilQualityEvaluation {
id: string;
fieldId: string;
fieldName: string;
date: string;
location: string;
indicators: SoilIndicator[];
overallScore: number;
overallGrade: 'A' | 'B' | 'C' | 'D' | 'E';
soilType: string;
texture: string;
drainage: 'excellent' | 'good' | 'moderate' | 'poor';
fertility: 'high' | 'medium' | 'low';
recommendations: SoilRecommendation[];
evaluationNotes: string;
evaluator: string;
nextEvaluationDate: string;
}
export interface SoilRecommendation {
category: 'fertilizer' | 'organic_matter' | 'ph_adjustment' | 'drainage' | 'crop_rotation' | 'other';
priority: 'high' | 'medium' | 'low';
title: string;
description: string;
actionItems: string[];
expectedImprovement: number;
timeframe: string;
estimatedCost: number;
costUnit: 'yuan_per_mu' | 'yuan_per_hectare' | 'yuan_total';
}
export interface SoilQualityHistory {
date: string;
overallScore: number;
indicatorChanges: {
indicatorId: string;
indicatorName: string;
previousValue: number;
currentValue: number;
change: number;
trend: 'improving' | 'declining' | 'stable';
}[];
evaluation: SoilQualityEvaluation;
}
export interface SoilQualityStandard {
region: string;
soilType: string;
indicators: {
[key: string]: {
optimalRange: { min: number; max: number };
acceptableRange: { min: number; max: number };
criticalRange: { min: number; max: number };
weight: number;
};
};
}
export interface FertilizerRecommendation {
nutrientType: 'nitrogen' | 'phosphorus' | 'potassium' | 'organic_matter' | 'micronutrients';
currentLevel: number;
targetLevel: number;
deficit: number;
recommendedAmount: number;
unit: string;
fertilizerOptions: {
name: string;
content: number;
applicationRate: number;
cost: number;
timing: string;
}[];
}
export interface SoilAnalysisForm {
fieldId: string;
sampleDate: string;
sampleDepth: number;
ph: number;
organicMatter: number;
nitrogen: number;
phosphorus: number;
potassium: number;
calcium: number;
magnesium: number;
sulfur: number;
iron: number;
manganese: number;
zinc: number;
copper: number;
boron: number;
soilTexture: string;
drainage: string;
notes: string;
}
// 土壤质量等级定义
export const SOIL_QUALITY_GRADES = {
A: { min: 90, max: 100, label: '优秀', color: '#22c55e', description: '土壤质量极佳,适合各种作物生长' },
B: { min: 80, max: 89, label: '良好', color: '#84cc16', description: '土壤质量良好,适合大部分作物生长' },
C: { min: 70, max: 79, label: '中等', color: '#eab308', description: '土壤质量中等,需要适当改良' },
D: { min: 60, max: 69, label: '较差', color: '#f97316', description: '土壤质量较差,需要重点改良' },
E: { min: 0, max: 59, label: '很差', color: '#ef4444', description: '土壤质量很差,需要全面改良' }
};
// 土壤指标定义
export const SOIL_INDICATORS = {
ph: {
id: 'ph',
name: 'pH值',
unit: '',
optimalRange: { min: 6.0, max: 7.5 },
weight: 0.15,
description: '土壤酸碱度,影响养分有效性和微生物活动'
},
organicMatter: {
id: 'organicMatter',
name: '有机质',
unit: '%',
optimalRange: { min: 2.5, max: 5.0 },
weight: 0.20,
description: '土壤有机质含量,反映土壤肥力和结构'
},
nitrogen: {
id: 'nitrogen',
name: '全氮',
unit: 'g/kg',
optimalRange: { min: 1.0, max: 2.0 },
weight: 0.15,
description: '土壤氮素含量,植物生长的重要营养元素'
},
phosphorus: {
id: 'phosphorus',
name: '有效磷',
unit: 'mg/kg',
optimalRange: { min: 15, max: 30 },
weight: 0.15,
description: '土壤有效磷含量,促进植物根系发育和开花结果'
},
potassium: {
id: 'potassium',
name: '速效钾',
unit: 'mg/kg',
optimalRange: { min: 100, max: 200 },
weight: 0.15,
description: '土壤速效钾含量,提高植物抗逆性和产量'
},
calcium: {
id: 'calcium',
name: '交换性钙',
unit: 'mg/kg',
optimalRange: { min: 1000, max: 2000 },
weight: 0.05,
description: '土壤钙含量,调节土壤结构和植物细胞壁形成'
},
magnesium: {
id: 'magnesium',
name: '交换性镁',
unit: 'mg/kg',
optimalRange: { min: 200, max: 400 },
weight: 0.05,
description: '土壤镁含量,叶绿素合成的重要元素'
},
sulfur: {
id: 'sulfur',
name: '有效硫',
unit: 'mg/kg',
optimalRange: { min: 10, max: 30 },
weight: 0.05,
description: '土壤硫含量,蛋白质合成的重要元素'
},
iron: {
id: 'iron',
name: '有效铁',
unit: 'mg/kg',
optimalRange: { min: 5, max: 20 },
weight: 0.025,
description: '土壤铁含量,叶绿素合成必需元素'
},
manganese: {
id: 'manganese',
name: '有效锰',
unit: 'mg/kg',
optimalRange: { min: 2, max: 10 },
weight: 0.025,
description: '土壤锰含量,参与植物光合作用'
},
zinc: {
id: 'zinc',
name: '有效锌',
unit: 'mg/kg',
optimalRange: { min: 1, max: 5 },
weight: 0.025,
description: '土壤锌含量,植物生长素合成的必需元素'
},
copper: {
id: 'copper',
name: '有效铜',
unit: 'mg/kg',
optimalRange: { min: 0.5, max: 2 },
weight: 0.025,
description: '土壤铜含量,参与植物呼吸作用'
},
boron: {
id: 'boron',
name: '有效硼',
unit: 'mg/kg',
optimalRange: { min: 0.5, max: 2 },
weight: 0.025,
description: '土壤硼含量,植物开花结果必需元素'
}
};
// 土壤类型定义
export const SOIL_TYPES = [
{ id: 'sandy_loam', name: '砂壤土', description: '排水良好,保肥能力中等' },
{ id: 'loam', name: '壤土', description: '质地适中,农业生产的理想土壤' },
{ id: 'clay_loam', name: '黏壤土', description: '保肥保水能力强,但排水较差' },
{ id: 'sandy', name: '砂土', description: '排水良好,但保肥能力差' },
{ id: 'clay', name: '黏土', description: '保肥保水能力强,通气性差' },
{ id: 'silt', name: '粉土', description: '肥力较高,但结构不稳定' }
];
// 土壤质地定义
export const SOIL_TEXTURES = [
{ id: 'coarse', name: '粗质地', description: '砂粒含量高,排水快' },
{ id: 'medium', name: '中等质地', description: '砂粒、粉粒、黏粒比例适中' },
{ id: 'fine', name: '细质地', description: '黏粒含量高,保水保肥能力强' }
];
// 排水等级定义
export const DRAINAGE_LEVELS = [
{ id: 'excellent', name: '优秀', description: '排水通畅,无积水现象' },
{ id: 'good', name: '良好', description: '排水较好,偶有短期积水' },
{ id: 'moderate', name: '中等', description: '排水一般,雨季有积水' },
{ id: 'poor', name: '较差', description: '排水困难,长期积水' }
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,207 @@
'use client';
import { Card } from '@/components/ui/card';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
interface DataTrendsProps {
timeRange: '1h' | '6h' | '24h' | '7d';
onTimeRangeChange: (value: '1h' | '6h' | '24h' | '7d') => void;
}
export function DataTrends({ timeRange, onTimeRangeChange }: DataTrendsProps) {
const environmentData = [
{ time: '00:00', temperature: 18, humidity: 75, co2: 380, light: 0, pm25: 25 },
{ time: '03:00', temperature: 16, humidity: 80, co2: 390, light: 0, pm25: 22 },
{ time: '06:00', temperature: 15, humidity: 85, co2: 400, light: 5000, pm25: 20 },
{ time: '09:00', temperature: 20, humidity: 70, co2: 410, light: 35000, pm25: 28 },
{ time: '12:00', temperature: 25, humidity: 55, co2: 420, light: 50000, pm25: 35 },
{ time: '15:00', temperature: 24.5, humidity: 62, co2: 420, light: 45000, pm25: 35 },
{ time: '18:00', temperature: 22, humidity: 65, co2: 410, light: 15000, pm25: 30 },
{ time: '21:00', temperature: 19, humidity: 72, co2: 395, light: 0, pm25: 26 },
];
return (
<div className="space-y-4">
<div className="flex gap-2">
{(['1h', '6h', '24h', '7d'] as const).map((range) => (
<button
key={range}
onClick={() => onTimeRangeChange(range)}
className={`px-3 py-1 rounded-lg text-sm transition-colors ${
timeRange === range
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{range === '1h' && '1小时'}
{range === '6h' && '6小时'}
{range === '24h' && '24小时'}
{range === '7d' && '7天'}
</button>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-6">
<h3 className="mb-4">线</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={environmentData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" className="dark:stroke-gray-600" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
label={{
value: '温度 (°C)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' },
className: "dark:fill-gray-400"
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value}°C`, '温度']}
/>
<Line
type="monotone"
dataKey="temperature"
stroke="#dc2626"
strokeWidth={2}
dot={{ fill: '#dc2626', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
<Card className="p-6">
<h3 className="mb-4">湿线</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={environmentData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" className="dark:stroke-gray-600" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
label={{
value: '湿度 (%)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' },
className: "dark:fill-gray-400"
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value}%`, '湿度']}
/>
<Line
type="monotone"
dataKey="humidity"
stroke="#2563eb"
strokeWidth={2}
dot={{ fill: '#2563eb', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
<Card className="p-6">
<h3 className="mb-4">CO线</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={environmentData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" className="dark:stroke-gray-600" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
label={{
value: 'CO₂ (ppm)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' },
className: "dark:fill-gray-400"
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value} ppm`, 'CO₂浓度']}
/>
<Line
type="monotone"
dataKey="co2"
stroke="#22c55e"
strokeWidth={2}
dot={{ fill: '#22c55e', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
<Card className="p-6">
<h3 className="mb-4">线</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={environmentData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" className="dark:stroke-gray-600" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
className="dark:fill-gray-400"
label={{
value: '光照 (lux)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' },
className: "dark:fill-gray-400"
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value} lux`, '光照强度']}
/>
<Line
type="monotone"
dataKey="light"
stroke="#eab308"
strokeWidth={2}
dot={{ fill: '#eab308', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,298 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Thermometer,
Droplets,
Leaf,
Eye,
Wind,
X
} from 'lucide-react';
import { SensorDevice, IoTDevice } from './environmentMonitoringReducer';
interface DeviceDialogProps {
showDeviceDialog: boolean;
editingDevice: SensorDevice | null;
deviceForm: {
iotDeviceId: string;
location: string;
fieldId: string;
};
availableIoTDevices: IoTDevice[];
onDeviceFormChange: (form: Partial<typeof deviceForm>) => void;
onSaveDevice: () => void;
onCancel: () => void;
}
interface DeviceViewDialogProps {
showViewDialog: boolean;
viewingDevice: SensorDevice | null;
onClose: () => void;
}
const availableFields = [
{ id: 'field-1', name: '东区1号地' },
{ id: 'field-2', name: '西区2号地' },
{ id: 'field-3', name: '南区3号地' },
{ id: 'field-4', name: '北区4号地' },
];
export function DeviceDialog({
showDeviceDialog,
editingDevice,
deviceForm,
availableIoTDevices,
onDeviceFormChange,
onSaveDevice,
onCancel
}: DeviceDialogProps) {
const getStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500';
case 'offline': return 'bg-gray-500';
case 'warning': return 'bg-yellow-500';
default: return 'bg-gray-500';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'online': return '在线';
case 'offline': return '离线';
case 'warning': return '警告';
default: return '未知';
}
};
return (
<Dialog open={showDeviceDialog} onOpenChange={onCancel}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingDevice ? '编辑设备' : '添加设备'}</DialogTitle>
<DialogDescription>
{editingDevice ? '修改设备信息' : '从物联设备数据接入中选择设备并配置'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!editingDevice && (
<div>
<Label></Label>
<Select
value={deviceForm.iotDeviceId}
onValueChange={(value) => onDeviceFormChange({ iotDeviceId: value })}
>
<SelectTrigger>
<SelectValue placeholder="请从物联设备数据接入中选择" />
</SelectTrigger>
<SelectContent>
{availableIoTDevices.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground text-center">
<br />
AI系统--
</div>
) : (
availableIoTDevices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.code} - {device.name} ({device.manufacturer} {device.model})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
<div>
<Label></Label>
<Select
value={deviceForm.fieldId}
onValueChange={(value) => onDeviceFormChange({ fieldId: value })}
>
<SelectTrigger>
<SelectValue placeholder="请选择地块" />
</SelectTrigger>
<SelectContent>
{availableFields.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
value={deviceForm.location}
onChange={(e) => onDeviceFormChange({ location: e.target.value })}
placeholder="如:地块中心位置"
/>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>
</Button>
<Button className="bg-green-600 hover:bg-green-700" onClick={onSaveDevice}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function DeviceViewDialog({
showViewDialog,
viewingDevice,
onClose
}: DeviceViewDialogProps) {
const getStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500';
case 'offline': return 'bg-gray-500';
case 'warning': return 'bg-yellow-500';
default: return 'bg-gray-500';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'online': return '在线';
case 'offline': return '离线';
case 'warning': return '警告';
default: return '未知';
}
};
return (
<Dialog open={showViewDialog} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{viewingDevice?.name} - {viewingDevice?.type}
</DialogDescription>
</DialogHeader>
{viewingDevice && (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label></Label>
<div className="mt-1 p-2 bg-muted rounded-md text-sm">{viewingDevice.name}</div>
</div>
<div>
<Label></Label>
<div className="mt-1 p-2 bg-muted rounded-md text-sm">{viewingDevice.type}</div>
</div>
<div>
<Label></Label>
<div className="mt-1 p-2 bg-muted rounded-md text-sm">{viewingDevice.fieldName}</div>
</div>
<div>
<Label></Label>
<div className="mt-1 p-2 bg-muted rounded-md text-sm">{viewingDevice.location}</div>
</div>
<div>
<Label></Label>
<div className="mt-1">
<Badge className={`${getStatusColor(viewingDevice.status)} text-white`}>
{getStatusLabel(viewingDevice.status)}
</Badge>
</div>
</div>
<div>
<Label></Label>
<div className="mt-1 p-2 bg-muted rounded-md text-sm">
<span className={viewingDevice.battery < 40 ? 'text-red-600 dark:text-red-400' : ''}>
{viewingDevice.battery}%
</span>
</div>
</div>
<div className="sm:col-span-2">
<Label></Label>
<div className="mt-1 p-2 bg-muted rounded-md text-sm">{viewingDevice.lastUpdate}</div>
</div>
</div>
<div>
<Label></Label>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-2">
{viewingDevice.sensors.temperature && (
<Card className="p-3 bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800">
<div className="flex items-center gap-2">
<Thermometer className="w-4 h-4 text-red-600 dark:text-red-400" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-medium">{viewingDevice.sensors.temperature}°C</p>
</div>
</div>
</Card>
)}
{viewingDevice.sensors.humidity && (
<Card className="p-3 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-xs text-muted-foreground">湿</p>
<p className="text-sm font-medium">{viewingDevice.sensors.humidity}%</p>
</div>
</div>
</Card>
)}
{viewingDevice.sensors.co2 && (
<Card className="p-3 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
<div className="flex items-center gap-2">
<Leaf className="w-4 h-4 text-green-600 dark:text-green-400" />
<div>
<p className="text-xs text-muted-foreground">CO</p>
<p className="text-sm font-medium">{viewingDevice.sensors.co2} ppm</p>
</div>
</div>
</Card>
)}
{viewingDevice.sensors.light && (
<Card className="p-3 bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-medium">{(viewingDevice.sensors.light / 1000).toFixed(1)}k lux</p>
</div>
</div>
</Card>
)}
{viewingDevice.sensors.pm25 && (
<Card className="p-3 bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-2">
<Wind className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<div>
<p className="text-xs text-muted-foreground">PM2.5</p>
<p className="text-sm font-medium">{viewingDevice.sensors.pm25} μg/m³</p>
</div>
</div>
</Card>
)}
</div>
</div>
<div className="flex justify-end pt-4 border-t">
<Button variant="outline" onClick={onClose}>
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Eye, Edit, Trash2, Battery } from 'lucide-react';
import { SensorDevice } from './environmentMonitoringReducer';
interface DeviceManagementProps {
sensorDevices: SensorDevice[];
onViewDevice: (device: SensorDevice) => void;
onEditDevice: (device: SensorDevice) => void;
onDeleteDevice: (deviceId: string) => void;
}
export function DeviceManagement({
sensorDevices,
onViewDevice,
onEditDevice,
onDeleteDevice
}: DeviceManagementProps) {
const getStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500';
case 'offline': return 'bg-gray-500';
case 'warning': return 'bg-yellow-500';
default: return 'bg-gray-500';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'online': return '在线';
case 'offline': return '离线';
case 'warning': return '警告';
default: return '未知';
}
};
return (
<Card className="p-4">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
</tr>
</thead>
<tbody>
{sensorDevices.map((device) => (
<tr key={device.id} className="border-b hover:bg-muted/50">
<td className="p-2">{device.name}</td>
<td className="p-2">{device.type}</td>
<td className="p-2">{device.fieldName}</td>
<td className="p-2">{device.location}</td>
<td className="p-2">
<Badge variant="outline" className={`${getStatusColor(device.status)} text-white`}>
{getStatusLabel(device.status)}
</Badge>
</td>
<td className="p-2">
<div className="flex items-center gap-1">
<Battery className={`w-4 h-4 ${device.battery < 40 ? 'text-red-500' : 'text-green-500'}`} />
<span className={device.battery < 40 ? 'text-red-600 dark:text-red-400' : ''}>
{device.battery}%
</span>
</div>
</td>
<td className="p-2 text-sm text-muted-foreground">{device.lastUpdate}</td>
<td className="p-2">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onViewDevice(device)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onEditDevice(device)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteDevice(device.id)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, AlertCircle, Radio, Activity } from 'lucide-react';
import { SensorDevice } from './environmentMonitoringReducer';
interface DeviceOverviewProps {
sensorDevices: SensorDevice[];
}
export function DeviceOverview({ sensorDevices }: DeviceOverviewProps) {
const onlineDevices = sensorDevices.filter(d => d.status === 'online').length;
const warningDevices = sensorDevices.filter(d => d.status === 'warning').length;
const offlineDevices = sensorDevices.filter(d => d.status === 'offline').length;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">线</p>
<p className="mt-2 text-2xl text-green-600 dark:text-green-400">{onlineDevices}</p>
</div>
<CheckCircle2 className="w-10 h-10 text-green-600 dark:text-green-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-gradient-to-br from-yellow-50 to-yellow-100 dark:from-yellow-950 dark:to-yellow-900 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-yellow-600 dark:text-yellow-400">{warningDevices}</p>
</div>
<AlertCircle className="w-10 h-10 text-yellow-600 dark:text-yellow-400 opacity-50" />
</div>
</Card>
<Card className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 border-gray-200 dark:border-gray-600">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">线</p>
<p className="mt-2 text-2xl text-muted-foreground">{offlineDevices}</p>
</div>
<Radio className="w-10 h-10 text-muted-foreground opacity-50" />
</div>
</Card>
<Card className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-blue-600 dark:text-blue-400">{sensorDevices.length}</p>
</div>
<Activity className="w-10 h-10 text-blue-600 dark:text-blue-400 opacity-50" />
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Thermometer,
Droplets,
Leaf,
Eye,
Wind,
TrendingUp,
Battery
} from 'lucide-react';
import { SensorDevice } from './environmentMonitoringReducer';
interface RealTimeMonitoringProps {
selectedDevice: string;
sensorDevices: SensorDevice[];
onDeviceChange: (value: string) => void;
}
export function RealTimeMonitoring({
selectedDevice,
sensorDevices,
onDeviceChange
}: RealTimeMonitoringProps) {
const currentDevice = sensorDevices.find(d => d.id === selectedDevice) || sensorDevices[0];
const getStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500';
case 'offline': return 'bg-gray-500';
case 'warning': return 'bg-yellow-500';
default: return 'bg-gray-500';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'online': return '在线';
case 'offline': return '离线';
case 'warning': return '警告';
default: return '未知';
}
};
const getCO2Level = (co2: number) => {
if (co2 < 400) return { label: '正常', color: 'text-green-600 dark:text-green-400' };
if (co2 < 600) return { label: '良好', color: 'text-blue-600 dark:text-blue-400' };
if (co2 < 1000) return { label: '偏高', color: 'text-yellow-600 dark:text-yellow-400' };
return { label: '过高', color: 'text-red-600 dark:text-red-400' };
};
const getPM25Level = (pm25: number) => {
if (pm25 < 35) return { label: '优', color: 'text-green-600 dark:text-green-400' };
if (pm25 < 75) return { label: '良', color: 'text-blue-600 dark:text-blue-400' };
if (pm25 < 115) return { label: '轻度污染', color: 'text-yellow-600 dark:text-yellow-400' };
return { label: '重度污染', color: 'text-red-600 dark:text-red-400' };
};
return (
<Card className="p-4">
<div className="flex items-center gap-4 mb-4 flex-wrap">
<Label className="text-sm">:</Label>
<Select value={selectedDevice} onValueChange={onDeviceChange}>
<SelectTrigger className="w-80">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sensorDevices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} - {device.fieldName} - {device.type}
</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" className={`${getStatusColor(currentDevice.status)} text-white`}>
{getStatusLabel(currentDevice.status)}
</Badge>
<Badge variant="outline" className="flex items-center gap-1">
<Battery className={`w-3 h-3 ${currentDevice.battery < 40 ? 'text-red-500' : 'text-green-500'}`} />
: {currentDevice.battery}%
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Card className="p-4 bg-gradient-to-br from-red-50 to-red-100 dark:from-red-950 dark:to-red-900 border-red-200 dark:border-red-800">
<div className="flex items-center justify-between mb-2">
<Thermometer className="w-6 h-6 text-red-600 dark:text-red-400" />
<Badge variant="outline"></Badge>
</div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-red-600 dark:text-red-400">{currentDevice.sensors.temperature}°C</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1"> 0.5°C</p>
</Card>
<Card className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between mb-2">
<Droplets className="w-6 h-6 text-blue-600 dark:text-blue-400" />
<Badge variant="outline"></Badge>
</div>
<p className="text-xs text-muted-foreground">湿</p>
<p className="mt-2 text-2xl text-blue-600 dark:text-blue-400">{currentDevice.sensors.humidity}%</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1"> 3%</p>
</Card>
<Card className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 border-green-200 dark:border-green-800">
<div className="flex items-center justify-between mb-2">
<Leaf className="w-6 h-6 text-green-600 dark:text-green-400" />
<Badge variant="outline"></Badge>
</div>
<p className="text-xs text-muted-foreground">CO</p>
<p className="mt-2 text-2xl text-green-600 dark:text-green-400">{currentDevice.sensors.co2}</p>
<p className="text-xs text-muted-foreground mt-1">ppm</p>
</Card>
<Card className="p-4 bg-gradient-to-br from-yellow-50 to-yellow-100 dark:from-yellow-950 dark:to-yellow-900 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between mb-2">
<Eye className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
<Badge variant="outline"></Badge>
</div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-yellow-600 dark:text-yellow-400">{(currentDevice.sensors.light! / 1000).toFixed(0)}k</p>
<p className="text-xs text-muted-foreground mt-1">lux</p>
</Card>
<Card className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 border-purple-200 dark:border-purple-800">
<div className="flex items-center justify-between mb-2">
<Wind className="w-6 h-6 text-purple-600 dark:text-purple-400" />
<Badge variant="outline"></Badge>
</div>
<p className="text-xs text-muted-foreground">PM2.5</p>
<p className="mt-2 text-2xl text-purple-600 dark:text-purple-400">{currentDevice.sensors.pm25}</p>
<p className="text-xs text-muted-foreground mt-1">μg/m³</p>
</Card>
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card className="p-4 bg-gradient-to-br from-green-50 to-white dark:from-green-950 dark:to-card border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">CO</p>
<p className={`mt-2 ${getCO2Level(currentDevice.sensors.co2!).color}`}>
{getCO2Level(currentDevice.sensors.co2!).label}
</p>
</div>
<TrendingUp className="w-8 h-8 text-green-600 dark:text-green-400 opacity-30" />
</div>
</Card>
<Card className="p-4 bg-gradient-to-br from-purple-50 to-white dark:from-purple-950 dark:to-card border-purple-200 dark:border-purple-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className={`mt-2 ${getPM25Level(currentDevice.sensors.pm25!).color}`}>
{getPM25Level(currentDevice.sensors.pm25!).label}
</p>
</div>
<Wind className="w-8 h-8 text-purple-600 dark:text-purple-400 opacity-30" />
</div>
</Card>
</div>
</Card>
);
}

View File

@@ -0,0 +1,247 @@
'use client';
export interface SensorDevice {
id: string;
name: string;
type: string;
location: string;
fieldId: string;
fieldName: string;
status: 'online' | 'offline' | 'warning';
battery: number;
lastUpdate: string;
sensors: {
temperature?: number;
humidity?: number;
co2?: number;
light?: number;
pm25?: number;
};
}
export interface IoTDevice {
id: string;
code: string;
name: string;
type: string;
manufacturer: string;
model: string;
location: string;
fieldId: string;
fieldName: string;
protocol: string;
ipAddress?: string;
mqttTopic?: string;
status: string;
bindingStatus: '未绑定' | '已绑定';
bindingSystem?: string;
lastReportTime: string;
dataFrequency: string;
batteryLevel?: number;
signalStrength?: number;
sensors: {
name: string;
unit: string;
currentValue: number;
normalRange: string;
}[];
}
export interface EnvironmentMonitoringState {
selectedDevice: string;
showDeviceDialog: boolean;
showViewDialog: boolean;
editingDevice: SensorDevice | null;
viewingDevice: SensorDevice | null;
timeRange: '1h' | '6h' | '24h' | '7d';
availableIoTDevices: IoTDevice[];
sensorDevices: SensorDevice[];
deviceForm: {
iotDeviceId: string;
location: string;
fieldId: string;
};
}
export type EnvironmentMonitoringAction =
| { type: 'SET_SELECTED_DEVICE'; payload: string }
| { type: 'SET_SHOW_DEVICE_DIALOG'; payload: boolean }
| { type: 'SET_SHOW_VIEW_DIALOG'; payload: boolean }
| { type: 'SET_EDITING_DEVICE'; payload: SensorDevice | null }
| { type: 'SET_VIEWING_DEVICE'; payload: SensorDevice | null }
| { type: 'SET_TIME_RANGE'; payload: '1h' | '6h' | '24h' | '7d' }
| { type: 'SET_AVAILABLE_IOT_DEVICES'; payload: IoTDevice[] }
| { type: 'SET_SENSOR_DEVICES'; payload: SensorDevice[] }
| { type: 'SET_DEVICE_FORM'; payload: Partial<EnvironmentMonitoringState['deviceForm']> }
| { type: 'ADD_SENSOR_DEVICE'; payload: SensorDevice }
| { type: 'UPDATE_SENSOR_DEVICE'; payload: { id: string; updates: Partial<SensorDevice> } }
| { type: 'DELETE_SENSOR_DEVICE'; payload: string }
| { type: 'LOAD_IOT_DEVICES' }
| { type: 'RESET_DEVICE_FORM' };
const availableFields = [
{ id: 'field-1', name: '东区1号地' },
{ id: 'field-2', name: '西区2号地' },
{ id: 'field-3', name: '南区3号地' },
{ id: 'field-4', name: '北区4号地' },
];
const initialSensorDevices: SensorDevice[] = [
{
id: 'device-1',
name: 'ENV-001',
type: '综合环境监测站',
location: '东区1号地中心',
fieldId: 'field-1',
fieldName: '东区1号地',
status: 'online',
battery: 85,
lastUpdate: '2024-10-18 15:30',
sensors: {
temperature: 24.5,
humidity: 62,
co2: 420,
light: 45000,
pm25: 35,
},
},
{
id: 'device-2',
name: 'ENV-002',
type: '气象传感器',
location: '西区2号地北侧',
fieldId: 'field-2',
fieldName: '西区2号地',
status: 'online',
battery: 92,
lastUpdate: '2024-10-18 15:28',
sensors: {
temperature: 23.8,
humidity: 68,
co2: 410,
light: 42000,
pm25: 28,
},
},
{
id: 'device-3',
name: 'ENV-003',
type: 'CO2监测器',
location: '南区3号地温室',
fieldId: 'field-3',
fieldName: '南区3号地',
status: 'warning',
battery: 35,
lastUpdate: '2024-10-18 15:25',
sensors: {
temperature: 26.2,
humidity: 75,
co2: 550,
light: 38000,
pm25: 42,
},
},
];
export const initialState: EnvironmentMonitoringState = {
selectedDevice: 'device-1',
showDeviceDialog: false,
showViewDialog: false,
editingDevice: null,
viewingDevice: null,
timeRange: '24h',
availableIoTDevices: [],
sensorDevices: initialSensorDevices,
deviceForm: {
iotDeviceId: '',
location: '',
fieldId: '',
},
};
export function environmentMonitoringReducer(
state: EnvironmentMonitoringState,
action: EnvironmentMonitoringAction
): EnvironmentMonitoringState {
switch (action.type) {
case 'SET_SELECTED_DEVICE':
return { ...state, selectedDevice: action.payload };
case 'SET_SHOW_DEVICE_DIALOG':
return { ...state, showDeviceDialog: action.payload };
case 'SET_SHOW_VIEW_DIALOG':
return { ...state, showViewDialog: action.payload };
case 'SET_EDITING_DEVICE':
return { ...state, editingDevice: action.payload };
case 'SET_VIEWING_DEVICE':
return { ...state, viewingDevice: action.payload };
case 'SET_TIME_RANGE':
return { ...state, timeRange: action.payload };
case 'SET_AVAILABLE_IOT_DEVICES':
return { ...state, availableIoTDevices: action.payload };
case 'SET_SENSOR_DEVICES':
return { ...state, sensorDevices: action.payload };
case 'SET_DEVICE_FORM':
return {
...state,
deviceForm: { ...state.deviceForm, ...action.payload }
};
case 'ADD_SENSOR_DEVICE':
return {
...state,
sensorDevices: [...state.sensorDevices, action.payload]
};
case 'UPDATE_SENSOR_DEVICE':
return {
...state,
sensorDevices: state.sensorDevices.map(device =>
device.id === action.payload.id
? { ...device, ...action.payload.updates }
: device
)
};
case 'DELETE_SENSOR_DEVICE':
return {
...state,
sensorDevices: state.sensorDevices.filter(device => device.id !== action.payload)
};
case 'LOAD_IOT_DEVICES':
try {
const data = localStorage.getItem('smart_agriculture_ai_iot_devices');
if (data) {
const devices: IoTDevice[] = JSON.parse(data);
const environmentDevices = devices.filter(
d => d.type === '环境监测站' && d.bindingStatus === '未绑定'
);
return { ...state, availableIoTDevices: environmentDevices };
}
} catch (error) {
console.error('Failed to load IoT devices:', error);
}
return { ...state, availableIoTDevices: [] };
case 'RESET_DEVICE_FORM':
return {
...state,
deviceForm: {
iotDeviceId: '',
location: '',
fieldId: '',
}
};
default:
return state;
}
}

View File

@@ -1,18 +1,229 @@
'use client';
import { Card } from '@/components/ui/card';
import { useReducer, useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
environmentMonitoringReducer,
initialState,
EnvironmentMonitoringAction,
SensorDevice,
IoTDevice
} from './components/environmentMonitoringReducer';
import { DeviceOverview } from './components/DeviceOverview';
import { RealTimeMonitoring } from './components/RealTimeMonitoring';
import { DeviceManagement } from './components/DeviceManagement';
import { DataTrends } from './components/DataTrends';
import { DeviceDialog, DeviceViewDialog } from './components/DeviceDialog';
const availableFields = [
{ id: 'field-1', name: '东区1号地' },
{ id: 'field-2', name: '西区2号地' },
{ id: 'field-3', name: '南区3号地' },
{ id: 'field-4', name: '北区4号地' },
];
export default function EnvironmentPage() {
const [state, dispatch] = useReducer(environmentMonitoringReducer, initialState);
useEffect(() => {
dispatch({ type: 'LOAD_IOT_DEVICES' });
}, []);
const handleDeviceChange = (value: string) => {
dispatch({ type: 'SET_SELECTED_DEVICE', payload: value });
};
const handleTimeRangeChange = (value: '1h' | '6h' | '24h' | '7d') => {
dispatch({ type: 'SET_TIME_RANGE', payload: value });
};
const handleAddDevice = () => {
dispatch({ type: 'SET_EDITING_DEVICE', payload: null });
dispatch({ type: 'RESET_DEVICE_FORM' });
dispatch({ type: 'SET_SHOW_DEVICE_DIALOG', payload: true });
};
const handleEditDevice = (device: SensorDevice) => {
dispatch({ type: 'SET_EDITING_DEVICE', payload: device });
dispatch({ type: 'SET_DEVICE_FORM', payload: {
iotDeviceId: '',
location: device.location,
fieldId: device.fieldId,
}});
dispatch({ type: 'SET_SHOW_DEVICE_DIALOG', payload: true });
};
const handleViewDevice = (device: SensorDevice) => {
dispatch({ type: 'SET_VIEWING_DEVICE', payload: device });
dispatch({ type: 'SET_SHOW_VIEW_DIALOG', payload: true });
};
const handleDeleteDevice = (deviceId: string) => {
if (confirm('确定要删除该设备吗?')) {
dispatch({ type: 'DELETE_SENSOR_DEVICE', payload: deviceId });
toast.success('设备已删除');
}
};
const handleDeviceFormChange = (form: Partial<typeof state.deviceForm>) => {
dispatch({ type: 'SET_DEVICE_FORM', payload: form });
};
const handleSaveDevice = () => {
if (!state.deviceForm.fieldId) {
toast.error('请选择地块');
return;
}
const field = availableFields.find(f => f.id === state.deviceForm.fieldId);
if (!field) {
toast.error('无效的地块选择');
return;
}
if (state.editingDevice) {
// 编辑设备
dispatch({
type: 'UPDATE_SENSOR_DEVICE',
payload: {
id: state.editingDevice.id,
updates: {
location: state.deviceForm.location,
fieldId: state.deviceForm.fieldId,
fieldName: field.name,
}
}
});
toast.success('设备信息已更新');
} else {
// 添加新设备
if (!state.deviceForm.iotDeviceId) {
toast.error('请选择设备');
return;
}
const iotDevice = state.availableIoTDevices.find(d => d.id === state.deviceForm.iotDeviceId);
if (!iotDevice) {
toast.error('无效的设备选择');
return;
}
const newDevice: SensorDevice = {
id: `device-${Date.now()}`,
name: iotDevice.code,
type: iotDevice.type,
location: state.deviceForm.location,
fieldId: state.deviceForm.fieldId,
fieldName: field.name,
status: 'online',
battery: iotDevice.batteryLevel || 100,
lastUpdate: new Date().toLocaleString('zh-CN'),
sensors: {
temperature: 22,
humidity: 60,
co2: 400,
light: 40000,
pm25: 30,
},
};
dispatch({ type: 'ADD_SENSOR_DEVICE', payload: newDevice });
// 更新AI数据中心的设备绑定状态
try {
const allIoTDevices = localStorage.getItem('smart_agriculture_ai_iot_devices');
if (allIoTDevices) {
const devices: IoTDevice[] = JSON.parse(allIoTDevices);
const updatedDevices = devices.map(d =>
d.id === state.deviceForm.iotDeviceId
? { ...d, bindingStatus: '已绑定' as const, bindingSystem: '地块环境监测-环境监测' }
: d
);
localStorage.setItem('smart_agriculture_ai_iot_devices', JSON.stringify(updatedDevices));
}
} catch (error) {
console.error('Failed to update IoT device binding status:', error);
}
toast.success('设备添加成功');
}
dispatch({ type: 'SET_SHOW_DEVICE_DIALOG', payload: false });
};
const handleCancelDialog = () => {
dispatch({ type: 'SET_SHOW_DEVICE_DIALOG', payload: false });
dispatch({ type: 'SET_SHOW_VIEW_DIALOG', payload: false });
};
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/monitoring/environment
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-muted-foreground">
</p>
</div>
</Card>
<div className="flex gap-2">
<Button className="bg-green-600 hover:bg-green-700" onClick={handleAddDevice}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<DeviceOverview sensorDevices={state.sensorDevices} />
<RealTimeMonitoring
selectedDevice={state.selectedDevice}
sensorDevices={state.sensorDevices}
onDeviceChange={handleDeviceChange}
/>
<Tabs defaultValue="devices" className="w-full">
<TabsList>
<TabsTrigger value="devices"></TabsTrigger>
<TabsTrigger value="trends"></TabsTrigger>
</TabsList>
<TabsContent value="devices">
<DeviceManagement
sensorDevices={state.sensorDevices}
onViewDevice={handleViewDevice}
onEditDevice={handleEditDevice}
onDeleteDevice={handleDeleteDevice}
/>
</TabsContent>
<TabsContent value="trends">
<DataTrends
timeRange={state.timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
</TabsContent>
</Tabs>
<DeviceDialog
showDeviceDialog={state.showDeviceDialog}
editingDevice={state.editingDevice}
deviceForm={state.deviceForm}
availableIoTDevices={state.availableIoTDevices}
onDeviceFormChange={handleDeviceFormChange}
onSaveDevice={handleSaveDevice}
onCancel={handleCancelDialog}
/>
<DeviceViewDialog
showViewDialog={state.showViewDialog}
viewingDevice={state.viewingDevice}
onClose={handleCancelDialog}
/>
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { WeatherDataState, WeatherDataAction } from './weatherDataReducer';
interface ControlPanelProps {
state: WeatherDataState;
dispatch: React.Dispatch<WeatherDataAction>;
}
export default function ControlPanel({ state, dispatch }: ControlPanelProps) {
const handleFieldChange = (fieldId: string) => {
dispatch({ type: 'SET_FILTERS', payload: { selectedField: fieldId } });
};
const handleTimeRangeChange = (timeRange: '24h' | '7d' | '30d') => {
dispatch({ type: 'SET_FILTERS', payload: { timeRange } });
};
return (
<Card className="p-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-2 block"></label>
<Select value={state.filters.selectedField} onValueChange={handleFieldChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="field-1">1</SelectItem>
<SelectItem value="field-2">西2</SelectItem>
<SelectItem value="field-3">3</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-2 block"></label>
<div className="flex gap-2">
<Button
variant={state.filters.timeRange === '24h' ? 'default' : 'outline'}
size="sm"
onClick={() => handleTimeRangeChange('24h')}
className="flex-1"
>
24
</Button>
<Button
variant={state.filters.timeRange === '7d' ? 'default' : 'outline'}
size="sm"
onClick={() => handleTimeRangeChange('7d')}
className="flex-1"
>
7
</Button>
<Button
variant={state.filters.timeRange === '30d' ? 'default' : 'outline'}
size="sm"
onClick={() => handleTimeRangeChange('30d')}
className="flex-1"
>
30
</Button>
</div>
</div>
<div className="flex items-end">
<div className="text-xs text-muted-foreground text-right">
<div>:</div>
<div className="font-medium">{state.lastUpdateTime || '未更新'}</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { Card } from '@/components/ui/card';
import { WeatherData } from './weatherDataReducer';
import {
Thermometer,
Droplets,
Wind,
CloudRain,
Sun,
Activity,
} from 'lucide-react';
interface RealtimeWeatherCardsProps {
weatherData: WeatherData[];
}
export default function RealtimeWeatherCards({ weatherData }: RealtimeWeatherCardsProps) {
// 获取最新的数据(最后一个数据点)
const latestData = weatherData[weatherData.length - 1];
// 如果没有数据,显示占位符
if (!latestData) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{[1, 2, 3, 4, 5, 6].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-3 bg-muted rounded w-16"></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>
);
}
// 计算变化趋势(与上一个数据点比较)
const previousData = weatherData[weatherData.length - 2];
const getTrend = (current: number, previous?: number) => {
if (!previous) return null;
const diff = current - previous;
if (Math.abs(diff) < 0.1) return null;
return diff > 0 ? 'up' : 'down';
};
const tempTrend = getTrend(latestData.temperature, previousData?.temperature);
const humidityTrend = getTrend(latestData.humidity, previousData?.humidity);
const windTrend = getTrend(latestData.windSpeed, previousData?.windSpeed);
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{/* 温度 */}
<Card className="p-4 bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-red-600 dark:text-red-400 font-semibold">
{latestData.temperature}°C
</p>
{tempTrend && (
<p className={`text-xs mt-1 ${tempTrend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{tempTrend === 'up' ? '↑' : '↓'} {Math.abs(latestData.temperature - (previousData?.temperature || 0)).toFixed(1)}°C
</p>
)}
</div>
<Thermometer className="w-10 h-10 text-red-600 dark:text-red-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-xs text-muted-foreground">湿</p>
<p className="mt-2 text-2xl text-blue-600 dark:text-blue-400 font-semibold">
{latestData.humidity}%
</p>
{humidityTrend && (
<p className={`text-xs mt-1 ${humidityTrend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{humidityTrend === 'up' ? '↑' : '↓'} {Math.abs(latestData.humidity - (previousData?.humidity || 0)).toFixed(0)}%
</p>
)}
</div>
<Droplets className="w-10 h-10 text-blue-600 dark:text-blue-400 opacity-50" />
</div>
</Card>
{/* 风速 */}
<Card className="p-4 bg-cyan-50 dark:bg-cyan-950 border-cyan-200 dark:border-cyan-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-cyan-600 dark:text-cyan-400 font-semibold">
{latestData.windSpeed} m/s
</p>
{windTrend && (
<p className={`text-xs mt-1 ${windTrend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{windTrend === 'up' ? '↑' : '↓'} {Math.abs(latestData.windSpeed - (previousData?.windSpeed || 0)).toFixed(1)} m/s
</p>
)}
</div>
<Wind className="w-10 h-10 text-cyan-600 dark:text-cyan-400 opacity-50" />
</div>
</Card>
{/* 降雨量 */}
<Card className="p-4 bg-indigo-50 dark:bg-indigo-950 border-indigo-200 dark:border-indigo-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-indigo-600 dark:text-indigo-400 font-semibold">
{latestData.rainfall} mm
</p>
<p className="text-xs text-muted-foreground mt-1">
{weatherData.length > 0 ? '今日累计' : '暂无数据'}
</p>
</div>
<CloudRain className="w-10 h-10 text-indigo-600 dark:text-indigo-400 opacity-50" />
</div>
</Card>
{/* 光照强度 */}
<Card className="p-4 bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-yellow-600 dark:text-yellow-400 font-semibold">
{latestData.sunlight}
</p>
<p className="text-xs text-muted-foreground mt-1">W/m²</p>
</div>
<Sun className="w-10 h-10 text-yellow-600 dark:text-yellow-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-xs text-muted-foreground"></p>
<p className="mt-2 text-2xl text-purple-600 dark:text-purple-400 font-semibold">
{latestData.pressure}
</p>
<p className="text-xs text-muted-foreground mt-1">hPa</p>
</div>
<Activity className="w-10 h-10 text-purple-600 dark:text-purple-400 opacity-50" />
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { Card } from '@/components/ui/card';
import { Zap } 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">
<Zap 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>API接口</strong>: API</li>
<li> <strong></strong>: 15</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: API7</li>
<li> <strong></strong>: 3°C10m/s50mm35°C</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: </li>
<li> <strong></strong>: CSV格式的气象数据</li>
</ul>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react';
import { WeatherDataState, WeatherDataAction, getAlertColor } from './weatherDataReducer';
interface WeatherAlertsProps {
state: WeatherDataState;
dispatch: React.Dispatch<WeatherDataAction>;
}
export default function WeatherAlerts({ state, dispatch }: WeatherAlertsProps) {
const unacknowledgedAlerts = state.weatherAlerts.filter(
alert => !state.acknowledgedAlerts.includes(alert.id)
);
const handleAcknowledgeAlert = (alertId: string) => {
dispatch({ type: 'ACKNOWLEDGE_ALERT', payload: alertId });
};
const handleAcknowledgeAll = () => {
dispatch({ type: 'ACKNOWLEDGE_ALL_ALERTS' });
};
if (unacknowledgedAlerts.length === 0) {
return null;
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-orange-800 dark:text-orange-200">
({unacknowledgedAlerts.length})
</h3>
{unacknowledgedAlerts.length > 1 && (
<Button
size="sm"
variant="outline"
onClick={handleAcknowledgeAll}
className="text-xs"
>
</Button>
)}
</div>
{unacknowledgedAlerts.map((alert) => (
<Card
key={alert.id}
className="p-4 border-l-4 border-l-orange-500 bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800"
>
<div className="flex items-start gap-4">
<AlertTriangle className="w-6 h-6 text-orange-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<Badge className={`${getAlertColor(alert.level)} text-white font-light`}>
{alert.level}
</Badge>
<Badge variant="outline" className="font-light">{alert.type}</Badge>
<span className="text-xs text-muted-foreground">{alert.time}</span>
</div>
<p className="text-sm mb-2 text-orange-800 dark:text-orange-200">{alert.message}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>:</span>
{alert.affectedFields.map((field, index) => (
<Badge key={index} variant="outline" className="font-light">
{field}
</Badge>
))}
</div>
</div>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => handleAcknowledgeAlert(alert.id)}
>
</Button>
</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,227 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { WeatherStatistics, WeatherData } from './weatherDataReducer';
interface WeatherAnalysisProps {
statistics: WeatherStatistics | null;
weatherData: WeatherData[];
}
export default function WeatherAnalysis({ statistics, weatherData }: WeatherAnalysisProps) {
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 getTemperatureLevel = (temp: number) => {
if (temp < 0) return { label: '严寒', color: 'text-blue-700' };
if (temp < 10) return { label: '寒冷', color: 'text-blue-600' };
if (temp < 20) return { label: '凉爽', color: 'text-green-600' };
if (temp < 30) return { label: '温暖', color: 'text-orange-500' };
return { label: '炎热', color: 'text-red-600' };
};
const getRainfallLevel = (rainfall: number) => {
if (rainfall === 0) return { label: '无雨', color: 'text-gray-500' };
if (rainfall < 10) return { label: '小雨', color: 'text-blue-400' };
if (rainfall < 25) return { label: '中雨', color: 'text-blue-500' };
if (rainfall < 50) return { label: '大雨', color: 'text-blue-600' };
return { label: '暴雨', color: 'text-blue-700' };
};
const getWindLevel = (speed: number) => {
if (speed < 1.6) return { label: '软风', color: 'text-green-500' };
if (speed < 3.4) return { label: '轻风', color: 'text-green-600' };
if (speed < 5.5) return { label: '微风', color: 'text-yellow-600' };
if (speed < 8.0) return { label: '和风', color: 'text-orange-500' };
if (speed < 10.8) return { label: '清风', color: 'text-orange-600' };
return { label: '强风', color: 'text-red-600' };
};
const avgTemp = statistics.temperature.avg;
const avgHumidity = statistics.other.avgHumidity;
const totalRainfall = statistics.precipitation.total;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 温度统计 */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-red-600 font-medium">{statistics.temperature.max}°C</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-blue-600 font-medium">{statistics.temperature.min}°C</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{statistics.temperature.avg}°C</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{statistics.temperature.range}°C</span>
</div>
<div className="flex justify-between items-center pt-2 border-t">
<span className="text-muted-foreground">:</span>
<Badge variant="outline" className={getTemperatureLevel(avgTemp).color}>
{getTemperatureLevel(avgTemp).label}
</Badge>
</div>
</div>
</Card>
{/* 降水统计 */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-blue-600 font-medium">{statistics.precipitation.total} mm</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{statistics.precipitation.maxHourly} mm/3h</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{statistics.precipitation.timeRange}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{weatherData.filter(d => d.rainfall > 0).length} </span>
</div>
<div className="flex justify-between items-center pt-2 border-t">
<span className="text-muted-foreground">:</span>
<Badge variant="outline" className={getRainfallLevel(totalRainfall).color}>
{getRainfallLevel(totalRainfall).label}
</Badge>
</div>
</div>
</Card>
{/* 风力统计 */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="text-cyan-600 font-medium">{statistics.wind.maxSpeed} m/s</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{statistics.wind.avgSpeed} m/s</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{statistics.wind.level}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{statistics.wind.direction}</span>
</div>
<div className="flex justify-between items-center pt-2 border-t">
<span className="text-muted-foreground">:</span>
<Badge variant="outline" className={getWindLevel(statistics.wind.avgSpeed).color}>
{getWindLevel(statistics.wind.avgSpeed).label}
</Badge>
</div>
</div>
</Card>
</div>
{/* 综合气象条件评价 */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="text-green-900 dark:text-green-100 mb-2 font-medium"></h4>
<ul className="text-sm text-green-800 dark:text-green-200 space-y-1">
<li> {avgTemp >= 15 && avgTemp <= 25 ? '利于作物生长' : '温度条件一般'}</li>
<li> {statistics.other.totalSunlight > 1000 ? '光照充足,光合作用强' : '光照条件适中'}</li>
<li> {totalRainfall > 0 && totalRainfall < 50 ? '适量降雨,补充土壤水分' : totalRainfall === 0 ? '天气干燥,需要灌溉' : '降雨较多,注意排水'}</li>
<li> {avgHumidity >= 40 && avgHumidity <= 70 ? '湿度适中,有利于作物发育' : '湿度条件需要关注'}</li>
</ul>
</div>
<div className="p-4 bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-lg">
<h4 className="text-orange-900 dark:text-orange-100 mb-2 font-medium"></h4>
<ul className="text-sm text-orange-800 dark:text-orange-200 space-y-1">
<li> {statistics.temperature.range > 15 ? '昼夜温差较大,注意保温措施' : '温差适中,作物适应性好'}</li>
<li> {statistics.wind.maxSpeed > 5 ? '风力较大,加固农业设施' : '风力条件良好'}</li>
<li> {statistics.other.avgPressure < 1000 ? '气压偏低,关注天气变化' : '气压稳定,天气状况良好'}</li>
<li> </li>
</ul>
</div>
</div>
</Card>
{/* 农业生产建议 */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 className="text-blue-900 dark:text-blue-100 mb-2 font-medium"></h4>
<p className="text-sm text-blue-800 dark:text-blue-200">
{avgTemp >= 15 && avgTemp <= 25 && totalRainfall < 30 ?
'当前气象条件适宜播种,建议抓住有利时机进行播种作业' :
'气象条件一般,建议等待更适宜的天气条件'
}
</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="text-green-900 dark:text-green-100 mb-2 font-medium"></h4>
<p className="text-sm text-green-800 dark:text-green-200">
{avgHumidity >= 50 && avgHumidity <= 70 ?
'湿度条件适宜,有利于肥料吸收和利用' :
'湿度条件需要调节,建议结合灌溉进行施肥'
}
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg">
<h4 className="text-purple-900 dark:text-purple-100 mb-2 font-medium"></h4>
<p className="text-sm text-purple-800 dark:text-purple-200">
{statistics.wind.avgSpeed < 3 ?
'风速较小,有利于喷药作业,建议进行病虫害防治' :
'风力较大,不适合喷药作业,建议等待风力减小'
}
</p>
</div>
</div>
{/* 气象风险等级 */}
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg">
<h4 className="text-gray-900 dark:text-gray-100 mb-2 font-medium"></h4>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<Badge
variant={statistics.wind.maxSpeed > 8 || statistics.precipitation.total > 50 ? 'destructive' :
statistics.wind.maxSpeed > 5 || statistics.precipitation.total > 25 ? 'default' : 'secondary'}
className="font-light"
>
{statistics.wind.maxSpeed > 8 || statistics.precipitation.total > 50 ? '高风险' :
statistics.wind.maxSpeed > 5 || statistics.precipitation.total > 25 ? '中等风险' : '低风险'}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,227 @@
'use client';
import { Card } from '@/components/ui/card';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { WeatherData } from './weatherDataReducer';
interface WeatherChartsProps {
weatherData: WeatherData[];
timeRange: '24h' | '7d' | '30d';
}
export default function WeatherCharts({ weatherData, timeRange }: WeatherChartsProps) {
// 根据时间范围调整图表高度
const chartHeight = timeRange === '24h' ? 280 : 220;
return (
<div className="space-y-4">
{/* 温度变化曲线 */}
<Card className="p-6">
<h3 className="mb-4">线</h3>
<ResponsiveContainer width="100%" height={chartHeight}>
<LineChart data={weatherData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
label={{
value: timeRange === '24h' ? '时间' : '日期',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#6b7280' }
}}
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
label={{
value: '温度 (°C)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' }
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value}°C`, '温度']}
/>
<Line
type="monotone"
dataKey="temperature"
stroke="#dc2626"
strokeWidth={2}
dot={{ fill: '#dc2626', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 湿度变化曲线 */}
<Card className="p-6">
<h3 className="mb-4">湿线</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={weatherData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
label={{
value: '湿度 (%)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' }
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value}%`, '湿度']}
/>
<Line
type="monotone"
dataKey="humidity"
stroke="#2563eb"
strokeWidth={2}
dot={{ fill: '#2563eb', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
{/* 风速变化曲线 */}
<Card className="p-6">
<h3 className="mb-4">线</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={weatherData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
label={{
value: '风速 (m/s)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' }
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value} m/s`, '风速']}
/>
<Line
type="monotone"
dataKey="windSpeed"
stroke="#06b6d4"
strokeWidth={2}
dot={{ fill: '#06b6d4', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
{/* 降雨量统计 */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={weatherData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
label={{
value: '降雨量 (mm)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' }
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value} mm`, '降雨量']}
/>
<Bar
dataKey="rainfall"
fill="#6366f1"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</Card>
{/* 光照强度曲线 */}
<Card className="p-6">
<h3 className="mb-4">线</h3>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={weatherData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#6b7280' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#6b7280' }}
label={{
value: '光照 (W/m²)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 12, fill: '#6b7280' }
}}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '6px'
}}
formatter={(value: number) => [`${value} W/m²`, '光照强度']}
/>
<Line
type="monotone"
dataKey="sunlight"
stroke="#eab308"
strokeWidth={2}
dot={{ fill: '#eab308', r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw } from 'lucide-react';
import { WeatherDataState, WeatherDataAction } from './weatherDataReducer';
import WeatherAlerts from './WeatherAlerts';
import RealtimeWeatherCards from './RealtimeWeatherCards';
import ControlPanel from './ControlPanel';
import WeatherCharts from './WeatherCharts';
import WeatherForecast from './WeatherForecast';
import WeatherHistory from './WeatherHistory';
import WeatherAnalysis from './WeatherAnalysis';
import UsageGuide from './UsageGuide';
interface WeatherContentProps {
state: WeatherDataState;
dispatch: React.Dispatch<WeatherDataAction>;
}
export default function WeatherContent({ state, dispatch }: WeatherContentProps) {
const handleRefreshData = async () => {
if (!state.weatherStation.enabled) {
return;
}
dispatch({ type: 'SET_REFRESHING', payload: true });
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1500));
// 生成新的测试数据
const newData = state.weatherData.map(d => ({
...d,
temperature: d.temperature + (Math.random() - 0.5) * 2,
humidity: Math.max(20, Math.min(95, d.humidity + (Math.random() - 0.5) * 5)),
windSpeed: Math.max(0, d.windSpeed + (Math.random() - 0.5) * 1),
rainfall: Math.max(0, d.rainfall + Math.random() * 2),
sunlight: Math.max(0, d.sunlight + (Math.random() - 0.5) * 50),
pressure: d.pressure + (Math.random() - 0.5) * 2,
}));
dispatch({ type: 'UPDATE_WEATHER_DATA', payload: newData });
dispatch({ type: 'SET_LAST_UPDATE_TIME', payload: new Date().toLocaleString('zh-CN') });
} catch (error) {
console.error('Failed to refresh weather data:', error);
} finally {
dispatch({ type: 'SET_REFRESHING', payload: false });
}
};
return (
<div className="space-y-6">
{/* 操作按钮区域 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl text-green-800 dark:text-green-200"></h2>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleRefreshData}
disabled={state.isRefreshing}
>
<RefreshCw className={`w-4 h-4 mr-2 ${state.isRefreshing ? 'animate-spin' : ''}`} />
{state.isRefreshing ? '刷新中...' : '刷新数据'}
</Button>
</div>
</div>
{/* 天气预警 */}
<WeatherAlerts state={state} dispatch={dispatch} />
{/* 实时气象数据卡片 */}
<RealtimeWeatherCards weatherData={state.weatherData} />
{/* 控制面板 */}
<ControlPanel state={state} dispatch={dispatch} />
{/* 主要内容区域 */}
<Tabs value={state.activeTab} onValueChange={(value) => dispatch({ type: 'SET_ACTIVE_TAB', payload: value })}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="charts">线</TabsTrigger>
<TabsTrigger value="forecast"></TabsTrigger>
<TabsTrigger value="history"></TabsTrigger>
<TabsTrigger value="analysis"></TabsTrigger>
</TabsList>
<TabsContent value="charts" className="space-y-4">
<WeatherCharts
weatherData={state.weatherData}
timeRange={state.filters.timeRange}
/>
</TabsContent>
<TabsContent value="forecast" className="space-y-4">
<WeatherForecast
weatherForecast={state.weatherForecast}
/>
</TabsContent>
<TabsContent value="history" className="space-y-4">
<WeatherHistory
weatherData={state.weatherData}
lastUpdateTime={state.lastUpdateTime}
/>
</TabsContent>
<TabsContent value="analysis" className="space-y-4">
<WeatherAnalysis
statistics={state.statistics}
weatherData={state.weatherData}
/>
</TabsContent>
</Tabs>
{/* 使用说明 */}
<UsageGuide />
</div>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { Card } from '@/components/ui/card';
import { WeatherForecast } from './weatherDataReducer';
import {
Sun,
Cloud,
CloudRain,
Wind,
} from 'lucide-react';
interface WeatherForecastProps {
weatherForecast: WeatherForecast[];
}
export default function WeatherForecast({ weatherForecast }: WeatherForecastProps) {
const getWeatherIcon = (icon: string) => {
switch (icon) {
case 'sun':
return <Sun className="w-8 h-8" />;
case 'cloud':
return <Cloud className="w-8 h-8" />;
case 'rain':
return <CloudRain className="w-8 h-8" />;
case 'wind':
return <Wind className="w-8 h-8" />;
default:
return <Cloud className="w-8 h-8" />;
}
};
const getIconColor = (weather: string) => {
switch (weather) {
case '晴':
return 'text-yellow-500';
case '多云':
return 'text-gray-500';
case '阴':
return 'text-gray-600';
case '小雨':
case '中雨':
case '大雨':
case '暴雨':
return 'text-blue-500';
case '大风':
return 'text-cyan-500';
default:
return 'text-gray-500';
}
};
return (
<div className="space-y-4">
{/* 7天天气预报卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
{weatherForecast.map((forecast, index) => (
<Card
key={index}
className="p-4 text-center hover:bg-muted/50 transition-colors cursor-pointer"
>
<p className="text-sm font-medium mb-2">{forecast.day}</p>
<div className={`flex justify-center mb-2 ${getIconColor(forecast.weather)}`}>
{getWeatherIcon(forecast.icon)}
</div>
<p className="text-xs text-muted-foreground mb-1">{forecast.weather}</p>
<p className="text-sm font-medium">{forecast.tempRange}</p>
{forecast.description && (
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
{forecast.description}
</p>
)}
</Card>
))}
</div>
{/* 趋势分析 */}
<Card className="p-6">
<h3 className="mb-4">7</h3>
<div className="space-y-4">
{/* 温度趋势图 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h4 className="text-sm font-medium mb-3"></h4>
<div className="relative h-32 border rounded-lg p-4 bg-gradient-to-b from-red-50 to-blue-50">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl font-bold text-red-600">18°C</div>
<div className="text-sm text-muted-foreground"></div>
<div className="text-xs text-green-600 mt-1"> 3°C vs </div>
</div>
</div>
{/* 简化的趋势线 */}
<svg className="absolute inset-0 w-full h-full" style={{ pointerEvents: 'none' }}>
<polyline
points="10,80 50,60 90,40 130,30 170,35 210,25 250,20"
fill="none"
stroke="#dc2626"
strokeWidth="2"
opacity="0.6"
/>
</svg>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-3"></h4>
<div className="space-y-2">
{[
{ day: '明天', probability: 20 },
{ day: '后天', probability: 80 },
{ day: '周四', probability: 10 },
{ day: '周五', probability: 30 },
].map((item, index) => (
<div key={index} className="flex items-center gap-3">
<span className="text-xs w-12">{item.day}</span>
<div className="flex-1 h-4 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${
item.probability > 60 ? 'bg-blue-500' :
item.probability > 30 ? 'bg-blue-400' : 'bg-green-400'
}`}
style={{ width: `${item.probability}%` }}
/>
</div>
<span className="text-xs w-10 text-right">{item.probability}%</span>
</div>
))}
</div>
</div>
</div>
{/* 综合天气分析 */}
<div className="p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-3"></h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-blue-800 dark:text-blue-200">
<div>
<p><strong></strong>28°C</p>
<p><strong></strong></p>
</div>
<div>
<p><strong></strong></p>
<p><strong></strong></p>
</div>
</div>
</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>
<ul className="text-sm text-amber-800 dark:text-amber-200 space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { WeatherData } from './weatherDataReducer';
import { Download, Search, Calendar } from 'lucide-react';
interface WeatherHistoryProps {
weatherData: WeatherData[];
lastUpdateTime: string;
}
export default function WeatherHistory({ weatherData, lastUpdateTime }: WeatherHistoryProps) {
const handleExportData = () => {
// 生成CSV数据
const headers = ['时间', '温度(°C)', '湿度(%)', '风速(m/s)', '降雨量(mm)', '光照(W/m²)', '气压(hPa)'];
const rows = weatherData.map(data => [
data.time,
data.temperature,
data.humidity,
data.windSpeed,
data.rainfall,
data.sunlight,
data.pressure,
]);
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);
};
return (
<div className="space-y-4">
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"></h3>
<div className="flex gap-2">
<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="搜索时间点..."
className="pl-10 w-48"
/>
</div>
<Button variant="outline" size="sm" onClick={handleExportData}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 数据概览 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 p-4 bg-muted rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{weatherData.length}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{Math.max(...weatherData.map(d => d.temperature))}°C
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-cyan-600">
{weatherData.reduce((sum, d) => sum + d.rainfall, 0)}mm
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{Math.round(weatherData.reduce((sum, d) => sum + d.windSpeed, 0) / weatherData.length)}m/s
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
{/* 数据表格 */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium"></th>
<th className="px-4 py-3 text-center text-xs font-medium">(°C)</th>
<th className="px-4 py-3 text-center text-xs font-medium">湿(%)</th>
<th className="px-4 py-3 text-center text-xs font-medium">(m/s)</th>
<th className="px-4 py-3 text-center text-xs font-medium">(mm)</th>
<th className="px-4 py-3 text-center text-xs font-medium">(W/m²)</th>
<th className="px-4 py-3 text-center text-xs font-medium">(hPa)</th>
</tr>
</thead>
<tbody>
{weatherData.map((data, index) => (
<tr
key={index}
className="border-t hover:bg-muted/50 transition-colors"
>
<td className="px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-muted-foreground" />
{data.time}
</div>
</td>
<td className="px-4 py-3 text-center">
<Badge
variant={data.temperature > 25 ? 'destructive' : data.temperature < 15 ? 'secondary' : 'outline'}
className="font-light"
>
{data.temperature}
</Badge>
</td>
<td className="px-4 py-3 text-center">
<Badge
variant={data.humidity > 70 ? 'outline' : 'secondary'}
className="font-light"
>
{data.humidity}
</Badge>
</td>
<td className="px-4 py-3 text-center">
<Badge
variant={data.windSpeed > 5 ? 'destructive' : 'outline'}
className="font-light"
>
{data.windSpeed}
</Badge>
</td>
<td className="px-4 py-3 text-center">
{data.rainfall > 0 ? (
<Badge variant="outline" className="font-light bg-blue-50 text-blue-700 border-blue-200">
{data.rainfall}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
<Badge
variant={data.sunlight > 500 ? 'outline' : 'secondary'}
className="font-light"
>
{data.sunlight}
</Badge>
</td>
<td className="px-4 py-3 text-center text-sm">
{data.pressure}
</td>
</tr>
))}
</tbody>
</table>
</div>
{weatherData.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p></p>
<p className="text-sm mt-1"></p>
</div>
)}
{/* 数据更新信息 */}
<div className="mt-4 pt-4 border-t text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>{weatherData.length > 0 ? '自动气象站' : '暂无数据'}</span>
<span>{lastUpdateTime || '未更新'}</span>
</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">
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{weatherData.filter(d => d.temperature > 30).length} </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{weatherData.filter(d => d.temperature < 10).length} </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{weatherData.filter(d => d.temperature >= 15 && d.temperature <= 25).length} </span>
</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 className="font-medium">{weatherData.filter(d => d.rainfall > 0).length} </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{Math.max(...weatherData.map(d => d.rainfall))} mm</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium"></span>
</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 className="font-medium">{weatherData.filter(d => d.windSpeed > 5).length} </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium">{weatherData.filter(d => d.windSpeed < 1).length} </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">:</span>
<span className="font-medium"></span>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,479 @@
'use client';
import { toast } from 'sonner';
// 气象数据接口
export interface WeatherData {
time: string;
temperature: number;
humidity: number;
windSpeed: number;
rainfall: number;
sunlight: number;
pressure: number;
}
// 天气预警接口
export interface WeatherAlert {
id: string;
type: '低温冻害' | '大风' | '暴雨' | '高温';
level: '预警' | '警报' | '紧急';
message: string;
time: string;
affectedFields: string[];
}
// 天气预报接口
export interface WeatherForecast {
day: string;
weather: string;
tempRange: string;
icon: string;
description?: string;
}
// 气象站配置接口
export interface WeatherStationConfig {
enabled: boolean;
apiEndpoint: string;
apiKey: string;
latitude: number;
longitude: number;
stationName: string;
updateInterval: number; // 分钟
}
// 筛选条件接口
export interface WeatherFilters {
selectedField: string;
timeRange: '24h' | '7d' | '30d';
}
// 统计分析结果接口
export interface WeatherStatistics {
temperature: {
max: number;
min: number;
avg: number;
range: number;
};
precipitation: {
total: number;
maxHourly: number;
timeRange: string;
intensity: string;
};
wind: {
maxSpeed: number;
avgSpeed: number;
level: string;
direction: string;
};
other: {
avgHumidity: number;
totalSunlight: number;
avgPressure: number;
};
}
// 状态接口
export interface WeatherDataState {
weatherData: WeatherData[];
weatherAlerts: WeatherAlert[];
weatherForecast: WeatherForecast[];
weatherStation: WeatherStationConfig;
filters: WeatherFilters;
statistics: WeatherStatistics | null;
activeTab: string;
acknowledgedAlerts: string[];
isRefreshing: boolean;
lastUpdateTime: string;
loading: boolean;
}
// Action类型
export type WeatherDataAction =
| { type: 'SET_WEATHER_DATA'; payload: WeatherData[] }
| { type: 'SET_WEATHER_ALERTS'; payload: WeatherAlert[] }
| { type: 'SET_WEATHER_FORECAST'; payload: WeatherForecast[] }
| { type: 'SET_WEATHER_STATION'; payload: WeatherStationConfig }
| { type: 'SET_FILTERS'; payload: Partial<WeatherFilters> }
| { type: 'SET_STATISTICS'; payload: WeatherStatistics | null }
| { type: 'SET_ACTIVE_TAB'; payload: string }
| { type: 'ACKNOWLEDGE_ALERT'; payload: string }
| { type: 'ACKNOWLEDGE_ALL_ALERTS' }
| { type: 'SET_REFRESHING'; payload: boolean }
| { type: 'SET_LAST_UPDATE_TIME'; payload: string }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'ADD_WEATHER_ALERT'; payload: WeatherAlert }
| { type: 'REMOVE_WEATHER_ALERT'; payload: string }
| { type: 'UPDATE_WEATHER_DATA'; payload: WeatherData[] }
| { type: 'LOAD_FROM_STORAGE' }
| { type: 'SAVE_TO_STORAGE' };
// 初始状态
export const initialWeatherDataState: WeatherDataState = {
weatherData: [],
weatherAlerts: [],
weatherForecast: [],
weatherStation: {
enabled: true,
apiEndpoint: 'https://api.weather.example.com/data',
apiKey: 'YOUR_API_KEY_HERE',
latitude: 39.9042,
longitude: 116.4074,
stationName: '智慧农场气象站',
updateInterval: 15,
},
filters: {
selectedField: 'field-1',
timeRange: '24h',
},
statistics: null,
activeTab: 'charts',
acknowledgedAlerts: [],
isRefreshing: false,
lastUpdateTime: '',
loading: false,
};
// 生成测试数据
const generateTestData = (timeRange: '24h' | '7d' | '30d'): WeatherData[] => {
if (timeRange === '24h') {
// 24小时数据 - 每3小时一个数据点
return [
{ time: '00:00', temperature: 12, humidity: 75, windSpeed: 2.5, rainfall: 0, sunlight: 0, pressure: 1013 },
{ time: '03:00', temperature: 10, humidity: 80, windSpeed: 2.0, rainfall: 0, sunlight: 0, pressure: 1012 },
{ time: '06:00', temperature: 9, humidity: 85, windSpeed: 1.8, rainfall: 0, sunlight: 50, pressure: 1012 },
{ time: '09:00', temperature: 15, humidity: 70, windSpeed: 3.2, rainfall: 0, sunlight: 450, pressure: 1013 },
{ time: '12:00', temperature: 22, humidity: 55, windSpeed: 4.5, rainfall: 0, sunlight: 850, pressure: 1014 },
{ time: '15:00', temperature: 25, humidity: 48, windSpeed: 5.2, rainfall: 0, sunlight: 680, pressure: 1013 },
{ time: '18:00', temperature: 20, humidity: 60, windSpeed: 3.8, rainfall: 2, sunlight: 150, pressure: 1012 },
{ time: '21:00', temperature: 16, humidity: 68, windSpeed: 3.0, rainfall: 5, sunlight: 0, pressure: 1011 },
];
} else if (timeRange === '7d') {
// 7天数据 - 每天一个数据点
return [
{ time: '10-12', temperature: 18, humidity: 68, windSpeed: 3.2, rainfall: 2, sunlight: 520, pressure: 1012 },
{ time: '10-13', temperature: 20, humidity: 62, windSpeed: 4.1, rainfall: 0, sunlight: 680, pressure: 1013 },
{ time: '10-14', temperature: 22, humidity: 58, windSpeed: 3.8, rainfall: 5, sunlight: 720, pressure: 1014 },
{ time: '10-15', temperature: 19, humidity: 72, windSpeed: 5.5, rainfall: 12, sunlight: 450, pressure: 1011 },
{ time: '10-16', temperature: 16, humidity: 78, windSpeed: 4.8, rainfall: 8, sunlight: 380, pressure: 1010 },
{ time: '10-17', temperature: 21, humidity: 65, windSpeed: 3.5, rainfall: 0, sunlight: 750, pressure: 1013 },
{ time: '10-18', temperature: 23, humidity: 60, windSpeed: 3.0, rainfall: 0, sunlight: 800, pressure: 1015 },
];
} else {
// 30天数据 - 每5天一个数据点
return [
{ time: '09-19', temperature: 15, humidity: 70, windSpeed: 3.5, rainfall: 15, sunlight: 480, pressure: 1011 },
{ time: '09-24', temperature: 17, humidity: 68, windSpeed: 4.0, rainfall: 8, sunlight: 550, pressure: 1012 },
{ time: '09-29', temperature: 19, humidity: 65, windSpeed: 3.8, rainfall: 5, sunlight: 620, pressure: 1013 },
{ time: '10-04', temperature: 21, humidity: 62, windSpeed: 4.2, rainfall: 3, sunlight: 680, pressure: 1014 },
{ time: '10-09', temperature: 20, humidity: 66, windSpeed: 3.9, rainfall: 10, sunlight: 600, pressure: 1012 },
{ time: '10-14', temperature: 22, humidity: 58, windSpeed: 3.5, rainfall: 2, sunlight: 720, pressure: 1015 },
{ time: '10-18', temperature: 23, humidity: 60, windSpeed: 3.0, rainfall: 0, sunlight: 800, pressure: 1015 },
];
}
};
// 生成测试预警数据
const generateTestAlerts = (): WeatherAlert[] => [
{
id: 'alert-1',
type: '大风',
level: '预警',
message: '预计明日下午将出现6-7级大风请做好防范措施',
time: '2024-10-15 14:30',
affectedFields: ['东区1号地', '西区2号地'],
},
{
id: 'alert-2',
type: '低温冻害',
level: '警报',
message: '预计后天凌晨最低温度将降至2℃注意防冻',
time: '2024-10-15 10:20',
affectedFields: ['南区3号地'],
},
];
// 生成测试预报数据
const generateTestForecast = (): WeatherForecast[] => [
{ day: '今天', weather: '多云', tempRange: '12-25°C', icon: 'cloud', description: '多云天气,温度适宜' },
{ day: '明天', weather: '大风', tempRange: '10-22°C', icon: 'wind', description: '风力较大,注意防范' },
{ day: '后天', weather: '小雨', tempRange: '8-18°C', icon: 'rain', description: '有小雨,携带雨具' },
{ day: '周四', weather: '晴', tempRange: '12-26°C', icon: 'sun', description: '晴朗天气,光照充足' },
{ day: '周五', weather: '多云', tempRange: '14-27°C', icon: 'cloud', description: '多云,温度上升' },
{ day: '周六', weather: '晴', tempRange: '15-28°C', icon: 'sun', description: '晴天,适宜户外活动' },
{ day: '周日', weather: '多云', tempRange: '13-25°C', icon: 'cloud', description: '多云,天气稳定' },
];
// 计算统计数据
const calculateStatistics = (data: WeatherData[]): WeatherStatistics => {
if (data.length === 0) {
return {
temperature: { max: 0, min: 0, avg: 0, range: 0 },
precipitation: { total: 0, maxHourly: 0, timeRange: '', intensity: '无' },
wind: { maxSpeed: 0, avgSpeed: 0, level: '无风', direction: '无' },
other: { avgHumidity: 0, totalSunlight: 0, avgPressure: 0 },
};
}
const temperatures = data.map(d => d.temperature);
const maxTemp = Math.max(...temperatures);
const minTemp = Math.min(...temperatures);
const avgTemp = temperatures.reduce((sum, t) => sum + t, 0) / temperatures.length;
const totalRainfall = data.reduce((sum, d) => sum + d.rainfall, 0);
const maxRainfall = Math.max(...data.map(d => d.rainfall));
const windSpeeds = data.map(d => d.windSpeed);
const maxWindSpeed = Math.max(...windSpeeds);
const avgWindSpeed = windSpeeds.reduce((sum, w) => sum + w, 0) / windSpeeds.length;
const avgHumidity = data.reduce((sum, d) => sum + d.humidity, 0) / data.length;
const totalSunlight = data.reduce((sum, d) => sum + d.sunlight, 0);
const avgPressure = data.reduce((sum, d) => sum + d.pressure, 0) / data.length;
return {
temperature: {
max: maxTemp,
min: minTemp,
avg: Math.round(avgTemp * 10) / 10,
range: maxTemp - minTemp,
},
precipitation: {
total: totalRainfall,
maxHourly: maxRainfall,
timeRange: '18:00-21:00',
intensity: totalRainfall > 50 ? '暴雨' : totalRainfall > 25 ? '大雨' : totalRainfall > 10 ? '中雨' : totalRainfall > 0 ? '小雨' : '无',
},
wind: {
maxSpeed: Math.round(maxWindSpeed * 10) / 10,
avgSpeed: Math.round(avgWindSpeed * 10) / 10,
level: maxWindSpeed > 10 ? '5-6级' : maxWindSpeed > 5 ? '3-4级' : '1-2级',
direction: '东南风',
},
other: {
avgHumidity: Math.round(avgHumidity),
totalSunlight,
avgPressure: Math.round(avgPressure),
},
};
};
// 检测灾害天气
const checkWeatherAlerts = (data: WeatherData[]): WeatherAlert[] => {
const alerts: WeatherAlert[] = [];
data.forEach((d) => {
// 低温冻害预警温度低于3°C
if (d.temperature <= 3) {
alerts.push({
id: `alert-freeze-${d.time}`,
type: '低温冻害',
level: '警报',
message: `预计${d.time}温度将降至${d.temperature}°C请做好防冻措施`,
time: new Date().toLocaleString('zh-CN'),
affectedFields: ['东区1号地', '西区2号地', '南区3号地'],
});
}
// 大风预警风速大于10m/s
if (d.windSpeed >= 10) {
alerts.push({
id: `alert-wind-${d.time}`,
type: '大风',
level: '预警',
message: `预计${d.time}风速将达到${d.windSpeed}m/s请加固设施`,
time: new Date().toLocaleString('zh-CN'),
affectedFields: ['东区1号地', '西区2号地'],
});
}
// 暴雨预警3小时降雨量大于50mm
if (d.rainfall >= 50) {
alerts.push({
id: `alert-rain-${d.time}`,
type: '暴雨',
level: '紧急',
message: `预计${d.time}降雨量将达到${d.rainfall}mm请注意排涝`,
time: new Date().toLocaleString('zh-CN'),
affectedFields: ['南区3号地'],
});
}
// 高温预警温度高于35°C
if (d.temperature >= 35) {
alerts.push({
id: `alert-heat-${d.time}`,
type: '高温',
level: '预警',
message: `预计${d.time}温度将达到${d.temperature}°C注意防暑降温`,
time: new Date().toLocaleString('zh-CN'),
affectedFields: ['东区1号地'],
});
}
});
return alerts;
};
// Reducer函数
export function weatherDataReducer(state: WeatherDataState, action: WeatherDataAction): WeatherDataState {
switch (action.type) {
case 'SET_WEATHER_DATA':
const statistics = calculateStatistics(action.payload);
const newAlerts = checkWeatherAlerts(action.payload);
return {
...state,
weatherData: action.payload,
statistics,
weatherAlerts: [...state.weatherAlerts, ...newAlerts],
};
case 'SET_WEATHER_ALERTS':
return { ...state, weatherAlerts: action.payload };
case 'SET_WEATHER_FORECAST':
return { ...state, weatherForecast: action.payload };
case 'SET_WEATHER_STATION':
return { ...state, weatherStation: action.payload };
case 'SET_FILTERS':
return {
...state,
filters: { ...state.filters, ...action.payload },
// 重新生成对应时间范围的数据
weatherData: generateTestData(action.payload.timeRange || state.filters.timeRange),
};
case 'SET_STATISTICS':
return { ...state, statistics: action.payload };
case 'SET_ACTIVE_TAB':
return { ...state, activeTab: action.payload };
case 'ACKNOWLEDGE_ALERT':
return {
...state,
acknowledgedAlerts: [...state.acknowledgedAlerts, action.payload],
};
case 'ACKNOWLEDGE_ALL_ALERTS':
const allAlertIds = state.weatherAlerts.map(alert => alert.id);
return {
...state,
acknowledgedAlerts: allAlertIds,
};
case 'SET_REFRESHING':
return { ...state, isRefreshing: action.payload };
case 'SET_LAST_UPDATE_TIME':
return { ...state, lastUpdateTime: action.payload };
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'ADD_WEATHER_ALERT':
return {
...state,
weatherAlerts: [action.payload, ...state.weatherAlerts],
};
case 'REMOVE_WEATHER_ALERT':
return {
...state,
weatherAlerts: state.weatherAlerts.filter(alert => alert.id !== action.payload),
};
case 'UPDATE_WEATHER_DATA':
const updatedStatistics = calculateStatistics(action.payload);
return {
...state,
weatherData: action.payload,
statistics: updatedStatistics,
};
case 'LOAD_FROM_STORAGE':
try {
const stored = localStorage.getItem('weatherData');
if (stored) {
const parsedData = JSON.parse(stored);
return {
...state,
...parsedData,
weatherData: parsedData.weatherData || generateTestData('24h'),
weatherAlerts: parsedData.weatherAlerts || generateTestAlerts(),
weatherForecast: parsedData.weatherForecast || generateTestForecast(),
};
} else {
// 首次加载,初始化测试数据
const initialData = {
weatherData: generateTestData('24h'),
weatherAlerts: generateTestAlerts(),
weatherForecast: generateTestForecast(),
lastUpdateTime: new Date().toLocaleString('zh-CN'),
};
localStorage.setItem('weatherData', JSON.stringify(initialData));
return {
...state,
...initialData,
};
}
} catch (error) {
console.error('Failed to load weather data from storage:', error);
return state;
}
case 'SAVE_TO_STORAGE':
try {
const dataToSave = {
weatherStation: state.weatherStation,
filters: state.filters,
weatherData: state.weatherData,
weatherAlerts: state.weatherAlerts,
weatherForecast: state.weatherForecast,
lastUpdateTime: state.lastUpdateTime,
};
localStorage.setItem('weatherData', JSON.stringify(dataToSave));
} catch (error) {
console.error('Failed to save weather data to storage:', error);
}
return state;
default:
return state;
}
}
// 工具函数
export const getAlertColor = (level: string) => {
switch (level) {
case '预警': return 'bg-yellow-500';
case '警报': return 'bg-orange-500';
case '紧急': return 'bg-red-500';
default: return 'bg-gray-500';
}
};
export const getWeatherIcon = (weather: string) => {
switch (weather) {
case '晴': return 'sun';
case '多云': return 'cloud';
case '阴': return 'cloud';
case '小雨': return 'rain';
case '中雨': return 'rain';
case '大雨': return 'rain';
case '暴雨': return 'rain';
case '大风': return 'wind';
default: return 'cloud';
}
};
export const formatTemperature = (temp: number) => `${temp}°C`;
export const formatWindSpeed = (speed: number) => `${speed} m/s`;
export const formatRainfall = (rainfall: number) => `${rainfall} mm`;
export const formatSunlight = (sunlight: number) => `${sunlight} W/m²`;
export const formatPressure = (pressure: number) => `${pressure} hPa`;

View File

@@ -1,18 +1,27 @@
'use client';
import { useReducer, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { weatherDataReducer, initialWeatherDataState, WeatherDataState } from './components/weatherDataReducer';
import WeatherContent from './components/WeatherContent';
export default function WeatherPage() {
const [state, dispatch] = useReducer(weatherDataReducer, initialWeatherDataState);
useEffect(() => {
// 加载存储的数据
dispatch({ type: 'LOAD_FROM_STORAGE' });
}, []);
// 保存数据到localStorage
useEffect(() => {
dispatch({ type: 'SAVE_TO_STORAGE' });
}, [state.weatherData, state.weatherAlerts, state.filters]);
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/monitoring/weather
</p>
</div>
</Card>
<WeatherContent state={state} dispatch={dispatch} />
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

415
crop-x/temp_file.tsx Normal file
View File

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