生产管理系统前端 - 气象管理与环境监测提交

This commit is contained in:
2025-10-30 10:53:52 +08:00
parent 304edcbb38
commit 2aa93f941e
22 changed files with 4808 additions and 28 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'; 'use client';
import { Card } from '@/components/ui/card'; import { SoilQualityAnalysis } from './components/SoilQualityAnalysis';
export default function SoilQualityPage() { export default function SoilQualityPage() {
return ( return <SoilQualityAnalysis />;
<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>
);
} }

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'; '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() { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card className="p-6"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold"></h2> <div>
<div className="p-3 bg-muted rounded-lg mt-3"> <h2 className="text-green-800 dark:text-green-200"></h2>
<p className="text-sm"> <p className="text-muted-foreground">
<strong></strong> /land-information/monitoring/environment
</p> </p>
</div> </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> </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'; 'use client';
import { useReducer, useEffect } from 'react';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { weatherDataReducer, initialWeatherDataState, WeatherDataState } from './components/weatherDataReducer';
import WeatherContent from './components/WeatherContent';
export default function WeatherPage() { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2> <WeatherContent state={state} dispatch={dispatch} />
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/monitoring/weather
</p>
</div>
</Card>
</div> </div>
); );
} }