生产管理系统 - 地块分类与标签管理,统计分析2个页面开发

This commit is contained in:
2025-10-29 15:40:10 +08:00
parent df8e6bf515
commit 9340252c25
10 changed files with 2601 additions and 16 deletions

View File

@@ -0,0 +1,427 @@
# 开发项目规范
## pathland-information/archive/statisticsname统计分析页面开发经验
### 总体开发经验总结
在实现统计分析页面过程中我们遵循了以下8条核心开发规范确保代码质量、可维护性和用户体验的一致性。
### 1. shadcn 样式系统优先原则
**经验总结:**
- 优先使用shadcn的语义化颜色类`bg-card``bg-background`替代硬编码的Tailwind CSS颜色
- 避免使用 `bg-red-500` 等具体颜色值,改用 `bg-red-50 dark:bg-red-950` 等语义化类
- 统计卡片使用 `bg-green-50 dark:bg-green-950` 等主题感知的背景色
**最佳实践:**
```tsx
// ✅ 推荐写法
<Card className="p-4 bg-green-50 dark:bg-green-950">
<Card className="bg-card hover:bg-muted">
// ❌ 避免写法
<Card className="p-4 bg-green-100">
<Card className="bg-white hover:bg-gray-100">
```
### 2. 标签组件样式精确还原原则
**经验总结:**
- 严格按照参考文件实现标签的边框和颜色样式
- 使用 `style` 属性精确控制颜色确保1:1还原视觉效果
- 不同类型的标签有特定的样式特征需要保持一致
**关键实现:**
```tsx
// 土壤类型标签:前缀彩色圆点
<div className="w-2 h-2 rounded-full mr-2" style={{ backgroundColor: type.color }} />
// 种植模式标签前缀emoji + 固定绿色边框
<span className="mr-1">{mode.emoji}</span>
style={{
backgroundColor: filters.plantingModes.includes(mode.key) ? '#16a34a' : 'transparent',
borderColor: '#16a34a',
}}
// 自定义标签:纯色边框背景
style={{
backgroundColor: filters.tags.includes(tag.name) ? tag.color : 'transparent',
borderColor: tag.color,
}}
```
### 3. 最小化修改原则
**经验总结:**
- 严格遵循参考文件的功能边界,不添加多余功能
- 保持与原有系统的功能一致性
- 避免过度设计,专注核心功能实现
**实施要点:**
- 只实现参考文件中明确展示的功能
- 保持相同的用户交互流程
- 维持原有的数据结构和逻辑
### 4. 暗色主题全面支持原则
**经验总结:**
- 所有组件都必须支持暗色主题切换
- 使用 `dark:` 前缀提供暗色模式样式
- 确保在暗色主题下的可读性和视觉效果
**实现模式:**
```tsx
// 统计卡片暗色主题
<Card className="p-4 bg-green-50 dark:bg-green-950">
<div className="text-2xl text-green-600 dark:text-green-400">
// 背景和边框暗色主题
<Card className="bg-card hover:bg-muted">
<Card className="border-blue-200 dark:border-blue-800">
```
### 5. useReducer 状态管理架构原则
**经验总结:**
- 使用useReducer实现复杂状态管理避免prop drilling
- 通过dispatch实现跨组件状态同步
- 集中化状态逻辑,提高代码可维护性
**架构模式:**
```tsx
// Reducer定义
export interface LandStatisticsState {
fields: Land[];
filters: FilterCondition;
statistics: StatisticsResult | null;
chartType: 'bar' | 'pie';
}
// Action类型定义
export type LandStatisticsAction =
| { type: 'SET_FIELDS'; payload: Land[] }
| { type: 'UPDATE_FILTER'; payload: { key: keyof FilterCondition; value: any } }
| { type: 'SET_STATISTICS'; payload: StatisticsResult | null };
// 状态同步使用
const handleFilterChange = (key: keyof FilterCondition, value: any) => {
dispatch({ type: 'UPDATE_FILTER', payload: { key, value } });
};
```
### 6. 模块化组件架构原则
**经验总结:**
- 每个页面建立独立的components文件夹
- 按功能职责拆分组件,确保单一职责
- 组件间通过props和回调函数通信
**目录结构示例:**
```
src/app/(app)/land-information/archive/statistics/
├── page.tsx # 主页面
└── components/
├── landStatisticsReducer.tsx # 状态管理
├── FilterPanel.tsx # 筛选面板
├── StatisticsResults.tsx # 统计结果
└── UsageExamples.tsx # 使用示例
```
### 7. 完整依赖引用实现原则
**经验总结:**
- 仔细分析参考文件的import依赖确保完整实现
- 将所有引用的组件都实现在components目录中
- 保持与参考文件相同的组件结构和功能
**依赖检查清单:**
- UI组件Card, Button, Badge, Input, Label等
- 图标组件lucide-react图标
- 图表组件recharts相关组件
- 工具函数toast通知等
### 8. 1:1 功能还原实现原则
**经验总结:**
- 严格按照参考文件的功能逻辑实现
- 保持相同的用户交互体验
- 确保数据流和业务逻辑的一致性
**关键实现要点:**
- 筛选条件的多选逻辑
- 数据统计的计算方法
- 图表切换和显示逻辑
- 数据导出功能
## 开发工具和最佳实践
### 推荐工具链
- **状态管理**React useReducer
- **UI组件库**shadcn/ui
- **样式系统**Tailwind CSS + 语义化颜色
- **图表库**Recharts
- **图标库**Lucide React
- **通知系统**Sonner
### 代码质量保证
- TypeScript严格类型检查
- ESLint代码规范检查
- 组件props类型定义完整
- 状态管理逻辑清晰可维护
### 测试数据管理
- localStorage数据持久化
- 完整的测试数据覆盖所有业务场景
- 数据初始化和清理机制完善
通过遵循这些开发规范,我们可以确保代码的一致性、可维护性和用户体验的统一性。
---
## pathland-information/archive/statisticsname统计分析页面开发经验与问题解决
### 问题1图表横轴显示不完整
**问题描述:**
- 初始实现中,图表只显示有数据的土壤类型和种植模式
- 没有数据的项目在横轴上不显示,导致图表看起来不完整
**原始需求分析:**
- 土壤类型分布应显示所有定义的土壤类型即使数量为0
- 种植模式分布应显示所有定义的种植模式,提供完整的分类视图
**解决方案:**
- 修改数据计算逻辑,从"基于筛选结果生成数据"改为"基于所有定义的分类生成数据"
- 使用 `state.soilTypes.map()``state.plantingModes.map()` 确保显示所有定义的分类
**代码改进对比:**
```tsx
// ❌ 初始实现(只显示有数据的分类)
const soilTypeMap = new Map<string, { count: number; area: number }>();
filteredFields.forEach(f => {
const current = soilTypeMap.get(f.soilType) || { count: 0, area: 0 };
soilTypeMap.set(f.soilType, {
count: current.count + 1,
area: current.area + f.area,
});
});
const soilTypeDistribution = Array.from(soilTypeMap.entries()).map(([key, value]) => ({
name: state.soilTypes.find(s => s.key === key)?.name || key,
count: value.count,
area: value.area,
color: state.soilTypes.find(s => s.key === key)?.color || '#6b7280',
}));
// ✅ 最终实现显示所有定义的分类包括数量为0的
const soilTypeDistribution = state.soilTypes.map(soilType => {
const count = filteredFields.filter(f => f.soilType === soilType.key).length;
const area = filteredFields
.filter(f => f.soilType === soilType.key)
.reduce((sum, f) => sum + f.area, 0);
return {
name: soilType.name,
count,
area,
color: soilType.color,
};
});
```
### 问题2标签字体粗细不符合视觉要求
**问题描述:**
- 筛选标签(土壤类型、种植模式、自定义标签)的字体过粗
- 用户反馈需要调整为细字体,以匹配参考文件的视觉效果
**原始需求分析:**
- 参考文件显示的是细字效果,需要精确还原视觉体验
- 字体粗细影响整体UI的美观和专业度
**解决方案:**
- 给所有Badge组件添加 `font-light` 类名
- 保持其他样式(颜色、边框、悬停效果)不变,只调整字体粗细
**代码改进:**
```tsx
// ❌ 初始实现
<Badge
key={type.id}
variant={filters.soilTypes.includes(type.key) ? 'default' : 'outline'}
className="cursor-pointer"
style={{ backgroundColor: filters.soilTypes.includes(type.key) ? type.color : 'transparent' }}
>
{type.name}
</Badge>
// ✅ 最终实现(添加字体细体)
<Badge
key={type.id}
variant={filters.soilTypes.includes(type.key) ? 'default' : 'outline'}
className="cursor-pointer font-light"
style={{ backgroundColor: filters.soilTypes.includes(type.key) ? type.color : 'transparent' }}
>
{type.name}
</Badge>
```
### 问题3测试数据覆盖不完整影响演示效果
**问题描述:**
- localStorage中存在旧数据导致某些土壤类型和种植模式没有对应的地块数据
- 部分图表项目显示为空或缺失,影响演示效果和用户体验
**原始需求分析:**
- 所有土壤类型和种植模式都应该有对应的测试数据
- 确保图表能完整展示所有分类的统计数据即使是0也要显示
- 为用户提供完整的演示环境
**解决方案:**
- 创建完整的测试数据集覆盖所有7种土壤类型和5种种植模式
-`loadData` 函数中初始化这些测试数据,确保首次访问时有完整数据
- 通过localStorage持久化确保数据在页面刷新后仍然存在
**测试数据设计原则:**
```tsx
const testFields = [
{
id: '1',
code: 'TD001',
name: '东区沙质土试验田',
area: 85.5,
location: '东区1号地块',
soilType: 'sandy', // 沙质土
plantingMode: 'conventional', // 传统种植
tags: ['有机种植', '高产示范', '滴灌设施'],
// ...其他完整字段
},
// 总计10个地块确保每个土壤类型和种植模式都有覆盖
// 沙质土(2个)、黏质土(2个)、壤质土(2个)、泥炭土(1个)、石灰质土(1个)、粉质土(1个)、岩石土(1个)
// 传统种植(3个)、有机种植(3个)、温室种植(2个)、水培种植(1个)、气培种植(1个)
];
```
### 问题4语义化颜色类使用存在不一致
**问题描述:**
- 部分组件仍使用硬编码的Tailwind颜色类
- 没有充分利用shadcn的语义化颜色系统
- 在暗色主题下可能存在兼容性问题
**原始需求分析:**
- 优先使用 `bg-gray` 等语义化颜色类
- 避免写死的Tailwind CSS样式提高主题一致性
- 建立统一的颜色使用标准
**解决方案:**
- 全面检查并替换硬编码颜色为语义化颜色类
- 统计卡片使用 `bg-green-50 dark:bg-green-950` 等主题感知背景色
- 确保在暗色主题下的可读性和视觉效果一致性
**颜色使用改进:**
```tsx
// ❌ 不一致的硬编码实现
<Card className="p-4 bg-green-100">
<div className="text-green-600">
<Card className="bg-white hover:bg-gray-100">
// ✅ 统一的语义化颜色实现
<Card className="p-4 bg-green-50 dark:bg-green-950">
<div className="text-2xl text-green-600 dark:text-green-400">
<Card className="bg-card hover:bg-muted">
<Card className="border-blue-200 dark:border-blue-800">
```
### 问题5多组件状态同步和数据管理复杂性
**问题描述:**
- 多个组件之间需要共享状态使用prop传递会导致代码复杂且难以维护
- 数据更新时容易出现状态不一致的问题
- 缺乏集中化的状态管理机制
**原始需求分析:**
- 使用useReducer实现集中化状态管理
- 确保组件间数据同步的可靠性和性能
- 简化组件间的通信逻辑
**解决方案:**
- 设计完整的状态管理架构包括状态接口、Action类型和Reducer函数
- 通过dispatch实现跨组件状态更新避免prop drilling
- 使用localStorage进行数据持久化确保页面刷新后状态保持
- 建立清晰的数据流和状态更新模式
**状态管理架构设计:**
```tsx
// 完整的状态接口定义
export interface LandStatisticsState {
fields: Land[];
tags: LandTag[];
soilTypes: SoilType[];
plantingModes: PlantingMode[];
filters: FilterCondition;
statistics: StatisticsResult | null;
chartType: 'bar' | 'pie';
}
// 细粒度的Action类型定义
export type LandStatisticsAction =
| { type: 'SET_FIELDS'; payload: Land[] }
| { type: 'SET_TAGS'; payload: LandTag[] }
| { type: 'SET_SOIL_TYPES'; payload: SoilType[] }
| { type: 'SET_PLANTING_MODES'; payload: PlantingMode[] }
| { type: 'SET_FILTERS'; payload: FilterCondition }
| { type: 'UPDATE_FILTER'; payload: { key: keyof FilterCondition; value: any } }
| { type: 'TOGGLE_ARRAY_FILTER'; payload: { key: 'soilTypes' | 'plantingModes' | 'tags'; value: string } }
| { type: 'CLEAR_FILTERS' }
| { type: 'SET_STATISTICS'; payload: StatisticsResult | null }
| { type: 'SET_CHART_TYPE'; payload: 'bar' | 'pie' };
// 跨组件状态同步实现
const handleFilterChange = (key: keyof FilterCondition, value: any) => {
dispatch({ type: 'UPDATE_FILTER', payload: { key, value } });
};
const handleToggleArrayFilter = (key: 'soilTypes' | 'plantingModes' | 'tags', value: string) => {
const currentArray = state.filters[key];
const newArray = currentArray.includes(value)
? currentArray.filter(v => v !== value)
: [...currentArray, value];
dispatch({ type: 'TOGGLE_ARRAY_FILTER', payload: { key, value } });
};
```
## 开发经验对比总结
### 与原始要求的差异分析
| 原始要求 | 实际实现 | 差异说明 | 解决过程 |
|---------|---------|---------|---------|
| 使用shadcn语义化样式 | 完全实现 + 统一规范 | 需要建立统一的使用标准 | 全面替换硬编码颜色,建立语义化颜色使用指南 |
| 1:1还原标签样式 | 精确还原 + 字体优化 | 字体粗细需要调整以匹配视觉 | 添加font-light类名保持样式一致性 |
| 不动无关内容 | 完全遵循 | 严格保持功能边界,不添加多余功能 | 只实现参考文件中的明确功能 |
| 暗色主题支持 | 全面支持 | 需要系统化处理所有UI组件 | 使用dark:前缀系统化处理暗色主题 |
| useReducer状态管理 | 架构实现 + 最佳实践 | 需要设计状态同步机制和数据持久化 | 建立完整的状态管理架构和同步机制 |
| 模块化组件 | 完全拆分 | 需要合理的组件职责划分和通信机制 | 按功能领域拆分组件通过props和回调通信 |
| 完整依赖引用 | 1:1还原 | 需要仔细分析引用关系和依赖完整性 | 建立依赖检查清单,确保所有引用组件完整实现 |
| 1:1功能还原 | 完整实现 | 需要精确还原业务逻辑和用户体验 | 严格对照参考文件实现所有功能 |
### 关键学习点和改进
1. **数据完整性思维**:不仅要实现功能,还要考虑数据的完整性和演示效果的完整性
- 学会了从用户体验角度思考数据展示的完整性
- 理解了即使count为0也应该显示的重要性
2. **细节精确把控**:字体粗细、颜色、边框等视觉细节需要精确还原
- 培养了对UI细节的敏感度
- 掌握了通过用户反馈快速迭代优化的方法
3. **状态管理设计**useReducer不仅是技术选择更是架构设计决策
- 深入理解了状态管理的架构设计原则
- 掌握了跨组件状态同步的最佳实践
4. **渐进式优化**:在开发过程中根据反馈不断调整和改进
- 学会了灵活应对开发过程中的需求变化
- 建立了基于反馈的快速响应机制
5. **文档化习惯**:将开发过程中的经验和教训记录下来,形成知识积累
- 认识到文档化对团队协作和知识传承的重要性
- 建立了完整的开发规范文档体系

View File

@@ -0,0 +1,618 @@
'use client';
import { useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
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 { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Layers, MapPin, Plus, Edit, Trash2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
// 预设颜色
const PRESET_COLORS = [
'#f59e0b', '#22c55e', '#f97316', '#3b82f6',
'#a855f7', '#ef4444', '#6b7280', '#10b981',
'#8b5cf6', '#ec4899', '#14b8a6', '#fb923c'
];
// 预设emoji
const PRESET_EMOJIS = [
'🌾', '🏠', '🍎', '🌊', '🌱', '🌳', '🌻', '🌽',
'🥬', '🥕', '🍅', '🫑', '🌿', '🪴', '🍇', '🌲'
];
export function LandClassificationManagement() {
const [activeTab, setActiveTab] = useState('soil-types');
// 土壤类型
const [soilTypes, setSoilTypes] = useState<any[]>([]);
// 种植模式
const [plantingModes, setPlantingModes] = useState<any[]>([]);
// 对话框状态
const [showSoilDialog, setShowSoilDialog] = useState(false);
const [showModeDialog, setShowModeDialog] = useState(false);
const [editingSoilType, setEditingSoilType] = useState<any>(null);
const [editingMode, setEditingMode] = useState<any>(null);
// 表单数据
const [soilFormData, setSoilFormData] = useState({
name: '',
key: '',
description: '',
color: '#22c55e',
});
const [modeFormData, setModeFormData] = useState({
name: '',
key: '',
description: '',
emoji: '🌾',
});
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingItem, setDeletingItem] = useState<{ type: 'soil' | 'mode'; id: string } | null>(null);
// 加载数据
useEffect(() => {
loadSoilTypes();
loadPlantingModes();
}, []);
const loadSoilTypes = () => {
const stored = localStorage.getItem('land_soil_types');
if (stored) {
setSoilTypes(JSON.parse(stored));
} else {
const now = new Date().toISOString();
const defaultTypes = [
{ id: '1', name: '沙土', key: 'sandy', description: '透气性好,保水性差,适合根茎类作物', color: '#f59e0b', createdAt: now, updatedAt: now },
{ id: '2', name: '壤土', key: 'loam', description: '肥力高,适合多种作物,是最理想的土壤类型', color: '#22c55e', createdAt: now, updatedAt: now },
{ id: '3', name: '黏土', key: 'clay', description: '保水保肥能力强,透气性差', color: '#f97316', createdAt: now, updatedAt: now },
{ id: '4', name: '粉土', key: 'silt', description: '有机质丰富,适合水稻等作物', color: '#3b82f6', createdAt: now, updatedAt: now },
{ id: '5', name: '泥炭土', key: 'peat', description: '有机质含量极高,酸性土壤', color: '#a855f7', createdAt: now, updatedAt: now },
{ id: '6', name: '盐碱土', key: 'saline', description: '含盐量高,需要改良后使用', color: '#ef4444', createdAt: now, updatedAt: now },
{ id: '7', name: '其他', key: 'other', description: '其他类型土壤', color: '#6b7280', createdAt: now, updatedAt: now },
];
localStorage.setItem('land_soil_types', JSON.stringify(defaultTypes));
setSoilTypes(defaultTypes);
}
};
const loadPlantingModes = () => {
const stored = localStorage.getItem('land_planting_modes');
if (stored) {
setPlantingModes(JSON.parse(stored));
} else {
const now = new Date().toISOString();
const defaultModes = [
{ id: '1', name: '露地', key: 'open-field', description: '露天种植,依靠自然条件', emoji: '🌾', createdAt: now, updatedAt: now },
{ id: '2', name: '大棚', key: 'greenhouse', description: '温室大棚种植,可控环境', emoji: '🏠', createdAt: now, updatedAt: now },
{ id: '3', name: '果园', key: 'orchard', description: '果树种植区域', emoji: '🍎', createdAt: now, updatedAt: now },
{ id: '4', name: '水田', key: 'paddy', description: '水稻等水生作物种植', emoji: '🌊', createdAt: now, updatedAt: now },
{ id: '5', name: '旱地', key: 'dryland', description: '旱作物种植区域', emoji: '🌱', createdAt: now, updatedAt: now },
];
localStorage.setItem('land_planting_modes', JSON.stringify(defaultModes));
setPlantingModes(defaultModes);
}
};
// 土壤类型 - 新增
const handleAddSoilType = () => {
setEditingSoilType(null);
setSoilFormData({ name: '', key: '', description: '', color: '#22c55e' });
setShowSoilDialog(true);
};
// 土壤类型 - 编辑
const handleEditSoilType = (type: any) => {
setEditingSoilType(type);
setSoilFormData({
name: type.name,
key: type.key,
description: type.description,
color: type.color,
});
setShowSoilDialog(true);
};
// 土壤类型 - 保存
const handleSaveSoilType = () => {
if (!soilFormData.name.trim()) {
toast.error('请输入类型名称');
return;
}
if (!soilFormData.key.trim()) {
toast.error('请输入类型标识');
return;
}
const now = new Date().toISOString();
if (editingSoilType) {
// 编辑
const updatedTypes = soilTypes.map(type =>
type.id === editingSoilType.id
? { ...type, ...soilFormData, updatedAt: now }
: type
);
localStorage.setItem('land_soil_types', JSON.stringify(updatedTypes));
setSoilTypes(updatedTypes);
toast.success('土壤类型更新成功');
} else {
// 新增
const newType = {
id: `soil-${Date.now()}`,
...soilFormData,
createdAt: now,
updatedAt: now,
};
const updatedTypes = [...soilTypes, newType];
localStorage.setItem('land_soil_types', JSON.stringify(updatedTypes));
setSoilTypes(updatedTypes);
toast.success('土壤类型添加成功');
}
setShowSoilDialog(false);
// 触发自定义事件通知父组件刷新
window.dispatchEvent(new Event('landClassificationUpdated'));
};
// 种植模式 - 新增
const handleAddMode = () => {
setEditingMode(null);
setModeFormData({ name: '', key: '', description: '', emoji: '🌾' });
setShowModeDialog(true);
};
// 种植模式 - 编辑
const handleEditMode = (mode: any) => {
setEditingMode(mode);
setModeFormData({
name: mode.name,
key: mode.key,
description: mode.description,
emoji: mode.emoji,
});
setShowModeDialog(true);
};
// 种植模式 - 保存
const handleSaveMode = () => {
if (!modeFormData.name.trim()) {
toast.error('请输入模式名称');
return;
}
if (!modeFormData.key.trim()) {
toast.error('请输入模式标识');
return;
}
const now = new Date().toISOString();
if (editingMode) {
// 编辑
const updatedModes = plantingModes.map(mode =>
mode.id === editingMode.id
? { ...mode, ...modeFormData, updatedAt: now }
: mode
);
localStorage.setItem('land_planting_modes', JSON.stringify(updatedModes));
setPlantingModes(updatedModes);
toast.success('种植模式更新成功');
} else {
// 新增
const newMode = {
id: `mode-${Date.now()}`,
...modeFormData,
createdAt: now,
updatedAt: now,
};
const updatedModes = [...plantingModes, newMode];
localStorage.setItem('land_planting_modes', JSON.stringify(updatedModes));
setPlantingModes(updatedModes);
toast.success('种植模式添加成功');
}
setShowModeDialog(false);
// 触发自定义事件通知父组件刷新
window.dispatchEvent(new Event('landClassificationUpdated'));
};
const handleDeleteClick = (type: 'soil' | 'mode', id: string) => {
setDeletingItem({ type, id });
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (!deletingItem) return;
if (deletingItem.type === 'soil') {
const item = soilTypes.find(s => s.id === deletingItem.id);
if (soilTypes.length <= 1) {
toast.error('至少需要保留一个土壤类型');
setDeleteDialogOpen(false);
return;
}
const updatedTypes = soilTypes.filter(s => s.id !== deletingItem.id);
localStorage.setItem('land_soil_types', JSON.stringify(updatedTypes));
setSoilTypes(updatedTypes);
toast.success(`已删除土壤类型:${item?.name}`);
} else {
const item = plantingModes.find(m => m.id === deletingItem.id);
if (plantingModes.length <= 1) {
toast.error('至少需要保留一个种植模式');
setDeleteDialogOpen(false);
return;
}
const updatedModes = plantingModes.filter(m => m.id !== deletingItem.id);
localStorage.setItem('land_planting_modes', JSON.stringify(updatedModes));
setPlantingModes(updatedModes);
toast.success(`已删除种植模式:${item?.name}`);
}
setDeleteDialogOpen(false);
setDeletingItem(null);
// 触发自定义事件通知父组件刷新
window.dispatchEvent(new Event('landClassificationUpdated'));
};
return (
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="soil-types" className="flex items-center gap-2">
<Layers className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="planting-modes" className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="soil-types" className="mt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{soilTypes.length}
</p>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleAddSoilType}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="space-y-2">
{soilTypes.map((soilType) => (
<Card key={soilType.id} className="p-4 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: soilType.color }}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{soilType.name}</span>
<Badge variant="outline" className="text-xs">
{soilType.key}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{soilType.description}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditSoilType(soilType)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick('soil', soilType.id)}
disabled={soilTypes.length <= 1}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
</Card>
))}
</div>
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 使
</p>
</Card>
</div>
</TabsContent>
<TabsContent value="planting-modes" className="mt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{plantingModes.length}
</p>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleAddMode}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="space-y-2">
{plantingModes.map((mode) => (
<Card key={mode.id} className="p-4 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div className="text-2xl">{mode.emoji}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{mode.name}</span>
<Badge variant="outline" className="text-xs">
{mode.key}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{mode.description}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditMode(mode)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick('mode', mode.id)}
disabled={plantingModes.length <= 1}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
</Card>
))}
</div>
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 使
</p>
</Card>
</div>
</TabsContent>
</Tabs>
{/* 土壤类型新增/编辑对话框 */}
<Dialog open={showSoilDialog} onOpenChange={setShowSoilDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingSoilType ? '编辑土壤类型' : '新增土壤类型'}</DialogTitle>
<DialogDescription>
{editingSoilType ? '修改土壤类型信息' : '添加新的土壤类型'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="soil-key"> *</Label>
<Input
id="soil-key"
placeholder="如sandy"
value={soilFormData.key}
onChange={(e) => setSoilFormData({ ...soilFormData, key: e.target.value.toLowerCase() })}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="soil-name"> *</Label>
<Input
id="soil-name"
placeholder="如:沙土"
value={soilFormData.name}
onChange={(e) => setSoilFormData({ ...soilFormData, name: e.target.value })}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="soil-description"></Label>
<Textarea
id="soil-description"
placeholder="请输入类型描述..."
rows={3}
value={soilFormData.description}
onChange={(e) => setSoilFormData({ ...soilFormData, description: e.target.value })}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2 flex-wrap">
{PRESET_COLORS.map((color, index) => (
<button
key={`color-${index}`}
type="button"
className={`w-8 h-8 rounded-full border-2 ${
soilFormData.color === color ? 'border-green-600' : 'border-gray-300'
}`}
style={{ backgroundColor: color }}
onClick={() => setSoilFormData({ ...soilFormData, color })}
/>
))}
</div>
<div className="flex items-center gap-2 mt-2">
<Input
type="color"
value={soilFormData.color}
onChange={(e) => setSoilFormData({ ...soilFormData, color: e.target.value })}
className="w-20 h-10"
/>
<span className="text-sm text-muted-foreground">{soilFormData.color}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSoilDialog(false)}>
</Button>
<Button className="bg-green-600 hover:bg-green-700" onClick={handleSaveSoilType}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 种植模式新增/编辑对话框 */}
<Dialog open={showModeDialog} onOpenChange={setShowModeDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingMode ? '编辑种植模式' : '新增种植模式'}</DialogTitle>
<DialogDescription>
{editingMode ? '修改种植模式信息' : '添加新的种植模式'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="mode-key"> *</Label>
<Input
id="mode-key"
placeholder="如open-field"
value={modeFormData.key}
onChange={(e) => setModeFormData({ ...modeFormData, key: e.target.value.toLowerCase() })}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="mode-name"> *</Label>
<Input
id="mode-name"
placeholder="如:露地"
value={modeFormData.name}
onChange={(e) => setModeFormData({ ...modeFormData, name: e.target.value })}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="mode-description"></Label>
<Textarea
id="mode-description"
placeholder="请输入模式描述..."
rows={3}
value={modeFormData.description}
onChange={(e) => setModeFormData({ ...modeFormData, description: e.target.value })}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2 flex-wrap">
{PRESET_EMOJIS.map((emoji, index) => (
<button
key={`emoji-${index}`}
type="button"
className={`w-10 h-10 text-2xl rounded border-2 ${
modeFormData.emoji === emoji ? 'border-green-600 bg-green-50' : 'border-gray-300'
}`}
onClick={() => setModeFormData({ ...modeFormData, emoji })}
>
{emoji}
</button>
))}
</div>
<div className="flex items-center gap-2 mt-2">
<Input
type="text"
value={modeFormData.emoji}
onChange={(e) => setModeFormData({ ...modeFormData, emoji: e.target.value })}
className="w-20 h-10 text-center text-2xl"
maxLength={2}
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowModeDialog(false)}>
</Button>
<Button className="bg-green-600 hover:bg-green-700" onClick={handleSaveMode}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
使
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeletingItem(null)}></AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={confirmDelete}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
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 { Card } from '@/components/ui/card';
import { Edit, Trash2 } from 'lucide-react';
interface LandTagManagementProps {
tags: any[];
onSave: (tag: any) => void;
onDelete: (id: string) => void;
}
export function LandTagManagement({ tags, onSave, onDelete }: LandTagManagementProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string>('');
const [editingTag, setEditingTag] = useState<any>(null);
const [name, setName] = useState('');
const [color, setColor] = useState('#22c55e');
const [description, setDescription] = useState('');
const handleDeleteClick = (id: string) => {
setDeletingId(id);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
onDelete(deletingId);
setDeleteDialogOpen(false);
setDeletingId('');
};
const presetColors = [
'#22c55e', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444',
'#06b6d4', '#ec4899', '#6366f1', '#14b8a6', '#f97316'
];
const handleSave = () => {
if (!name.trim()) return;
const tag = {
id: editingTag?.id || `tag-${Date.now()}`,
name: name.trim(),
color,
description: description.trim(),
createdAt: editingTag?.createdAt || new Date().toISOString(),
};
onSave(tag);
resetForm();
};
const resetForm = () => {
setEditingTag(null);
setName('');
setColor('#22c55e');
setDescription('');
};
const startEdit = (tag: any) => {
setEditingTag(tag);
setName(tag.name);
setColor(tag.color);
setDescription(tag.description || '');
};
return (
<div className="space-y-6">
{/* 添加/编辑表单 */}
<Card className="p-4 bg-card">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tagName"></Label>
<Input
id="tagName"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="输入标签名称"
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{presetColors.map((presetColor) => (
<button
key={presetColor}
type="button"
className={`w-8 h-8 rounded-full border-2 ${
color === presetColor ? 'border-primary' : 'border-transparent'
}`}
style={{ backgroundColor: presetColor }}
onClick={() => setColor(presetColor)}
/>
))}
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded-full cursor-pointer"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tagDescription"></Label>
<Input
id="tagDescription"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="输入标签描述(可选)"
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label></Label>
<div>
<Badge style={{ backgroundColor: color, color: 'white' }}>
{name || '标签示例'}
</Badge>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} disabled={!name.trim()} className="bg-green-600 hover:bg-green-700">
{editingTag ? '更新标签' : '添加标签'}
</Button>
{editingTag && (
<Button variant="outline" onClick={resetForm}>
</Button>
)}
</div>
</div>
</Card>
{/* 标签列表 */}
<div className="space-y-2">
<Label> ({tags.length})</Label>
{tags.length === 0 ? (
<div className="text-center text-muted-foreground py-8 border rounded-md bg-muted">
</div>
) : (
<div className="grid grid-cols-1 gap-2 max-h-[300px] overflow-y-auto">
{tags.map((tag) => (
<Card key={tag.id} className="p-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex-1">
<Badge style={{ backgroundColor: tag.color, color: 'white' }}>
{tag.name}
</Badge>
{tag.description && (
<p className="text-xs text-muted-foreground mt-1">{tag.description}</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => startEdit(tag)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(tag.id)}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
使
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={confirmDelete}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,44 @@
export interface LandClassificationState {
fields: any[];
tags: any[];
soilTypes: any[];
plantingModes: any[];
showTagManagement: boolean;
showClassificationManagement: boolean;
}
export const initialState: LandClassificationState = {
fields: [],
tags: [],
soilTypes: [],
plantingModes: [],
showTagManagement: false,
showClassificationManagement: false,
};
export type LandClassificationAction =
| { type: 'SET_FIELDS'; payload: any[] }
| { type: 'SET_TAGS'; payload: any[] }
| { type: 'SET_SOIL_TYPES'; payload: any[] }
| { type: 'SET_PLANTING_MODES'; payload: any[] }
| { type: 'SET_SHOW_TAG_MANAGEMENT'; payload: boolean }
| { type: 'SET_SHOW_CLASSIFICATION_MANAGEMENT'; payload: boolean };
export function LandClassificationReducer(state: LandClassificationState, action: LandClassificationAction): LandClassificationState {
switch (action.type) {
case 'SET_FIELDS':
return { ...state, fields: action.payload };
case 'SET_TAGS':
return { ...state, tags: action.payload };
case 'SET_SOIL_TYPES':
return { ...state, soilTypes: action.payload };
case 'SET_PLANTING_MODES':
return { ...state, plantingModes: action.payload };
case 'SET_SHOW_TAG_MANAGEMENT':
return { ...state, showTagManagement: action.payload };
case 'SET_SHOW_CLASSIFICATION_MANAGEMENT':
return { ...state, showClassificationManagement: action.payload };
default:
return state;
}
}

View File

@@ -1,18 +1,311 @@
'use client'; 'use client';
import { useReducer, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Tag, Layers, BarChart3 } from 'lucide-react';
import { LandClassificationReducer, initialState } from './components/landClassificationReducer';
import { LandTagManagement } from './components/LandTagManagement';
import { LandClassificationManagement } from './components/LandClassificationManagement';
import { toast } from 'sonner';
export default function LandClassificationPage() {
const [state, dispatch] = useReducer(LandClassificationReducer, initialState);
useEffect(() => {
loadData();
}, []);
// 监听分类更新事件
useEffect(() => {
const handleClassificationUpdate = () => {
loadData();
};
window.addEventListener('landClassificationUpdated', handleClassificationUpdate);
return () => {
window.removeEventListener('landClassificationUpdated', handleClassificationUpdate);
};
}, []);
// 当分类管理对话框关闭时重新加载数据
const handleCloseClassificationDialog = (open: boolean) => {
dispatch({ type: 'SET_SHOW_CLASSIFICATION_MANAGEMENT', payload: open });
// 对话框关闭时重新加载数据
if (!open) {
loadData();
}
};
const loadData = () => {
// 加载地块数据
const fieldsData = localStorage.getItem('land_archive_data');
if (fieldsData) {
dispatch({ type: 'SET_FIELDS', payload: JSON.parse(fieldsData) });
}
// 加载土壤类型
const soilTypesData = localStorage.getItem('land_soil_types');
if (soilTypesData) {
dispatch({ type: 'SET_SOIL_TYPES', payload: JSON.parse(soilTypesData) });
} else {
// 初始化默认土壤类型
const defaultSoilTypes = [
{ id: '1', key: 'sandy', name: '沙质土', color: '#f59e0b' },
{ id: '2', key: 'clay', name: '黏质土', color: '#8b5cf6' },
{ id: '3', key: 'loam', name: '壤质土', color: '#22c55e' },
{ id: '4', key: 'peat', name: '泥炭土', color: '#06b6d4' },
{ id: '5', key: 'chalky', name: '石灰质土', color: '#ec4899' },
{ id: '6', key: 'silty', name: '粉质土', color: '#f97316' },
{ id: '7', key: 'rocky', name: '岩石土', color: '#6b7280' }
];
dispatch({ type: 'SET_SOIL_TYPES', payload: defaultSoilTypes });
localStorage.setItem('land_soil_types', JSON.stringify(defaultSoilTypes));
}
// 加载种植模式
const plantingModesData = localStorage.getItem('land_planting_modes');
if (plantingModesData) {
dispatch({ type: 'SET_PLANTING_MODES', payload: JSON.parse(plantingModesData) });
} else {
// 初始化默认种植模式
const defaultPlantingModes = [
{ id: '1', key: 'conventional', name: '传统种植', emoji: '🌾' },
{ id: '2', key: 'organic', name: '有机种植', emoji: '🌱' },
{ id: '3', key: 'greenhouse', name: '温室种植', emoji: '🏠' },
{ id: '4', key: 'hydroponic', name: '水培种植', emoji: '💧' },
{ id: '5', key: 'aeroponic', name: '气培种植', emoji: '☁️' }
];
dispatch({ type: 'SET_PLANTING_MODES', payload: defaultPlantingModes });
localStorage.setItem('land_planting_modes', JSON.stringify(defaultPlantingModes));
}
// 加载自定义标签
const tagsData = localStorage.getItem('land_archive_custom_tags');
if (tagsData) {
dispatch({ type: 'SET_TAGS', payload: JSON.parse(tagsData) });
} else {
// 初始化默认标签
const defaultTags = [
{ id: '1', name: '有机种植', color: '#22c55e', description: '符合有机种植标准的地块', createdAt: new Date().toISOString() },
{ id: '2', name: '高产示范', color: '#3b82f6', description: '高产示范田', createdAt: new Date().toISOString() },
{ id: '3', name: '滴灌设施', color: '#f97316', description: '配备滴灌系统的地块', createdAt: new Date().toISOString() },
{ id: '4', name: '智能监测', color: '#a855f7', description: '安装了智能监测设备的地块', createdAt: new Date().toISOString() },
{ id: '5', name: '生态种植', color: '#10b981', description: '采用生态循环种植模式', createdAt: new Date().toISOString() },
{ id: '6', name: '科技示范', color: '#ef4444', description: '农业科技示范地块', createdAt: new Date().toISOString() },
{ id: '7', name: '节水灌溉', color: '#06b6d4', description: '采用节水灌溉技术', createdAt: new Date().toISOString() },
{ id: '8', name: '绿色种植', color: '#84cc16', description: '绿色环保种植方式', createdAt: new Date().toISOString() },
];
dispatch({ type: 'SET_TAGS', payload: defaultTags });
localStorage.setItem('land_archive_custom_tags', JSON.stringify(defaultTags));
}
};
const handleSaveTag = (tag: any) => {
const existingTags = state.tags;
const existingIndex = existingTags.findIndex(t => t.id === tag.id);
let updatedTags;
if (existingIndex >= 0) {
// 更新现有标签
updatedTags = [...existingTags];
updatedTags[existingIndex] = tag;
toast.success('标签更新成功');
} else {
// 添加新标签
updatedTags = [...existingTags, tag];
toast.success('标签添加成功');
}
dispatch({ type: 'SET_TAGS', payload: updatedTags });
localStorage.setItem('land_archive_custom_tags', JSON.stringify(updatedTags));
};
const handleDeleteTag = (id: string) => {
const tag = state.tags.find(t => t.id === id);
if (tag) {
// 从所有地块中移除该标签
const updatedFields = state.fields.map(f => ({
...f,
tags: f.tags.filter(t => t !== tag.name)
}));
dispatch({ type: 'SET_FIELDS', payload: updatedFields });
localStorage.setItem('land_archive_data', JSON.stringify(updatedFields));
}
const updatedTags = state.tags.filter(t => t.id !== id);
dispatch({ type: 'SET_TAGS', payload: updatedTags });
localStorage.setItem('land_archive_custom_tags', JSON.stringify(updatedTags));
toast.success('标签删除成功');
};
// 按土壤类型统计 - 显示所有定义的类型
const soilTypeStats = state.soilTypes.map((type: any) => ({
key: type.key,
name: type.name,
color: type.color,
count: state.fields.filter((f: any) => f.soilType === type.key).length,
}));
// 按种植模式统计 - 显示所有定义的模式
const plantingModeStats = state.plantingModes.map((mode: any) => ({
key: mode.key,
name: mode.name,
emoji: mode.emoji,
count: state.fields.filter((f: any) => f.plantingMode === mode.key).length,
}));
// 按标签统计 - 显示所有定义的标签
const tagStats = state.tags.map(tag => ({
tag,
count: state.fields.filter((f: any) => f.tags.includes(tag.name)).length,
}));
export default function ClassificationPage() {
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-400"></h2>
<p className="text-sm"> <p className="text-muted-foreground">
<strong></strong> /land-information/archive/classification
</p> </p>
</div> </div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => dispatch({ type: 'SET_SHOW_TAG_MANAGEMENT', payload: true })}>
<Tag className="w-4 h-4 mr-2" />
</Button>
<Button className="bg-green-600 hover:bg-green-700" onClick={() => dispatch({ type: 'SET_SHOW_CLASSIFICATION_MANAGEMENT', payload: true })}>
<Layers className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 土壤类型统计 */}
<Card className="p-6 bg-card">
<div className="flex items-center gap-2 mb-4">
<Layers className="w-5 h-5 text-green-600" />
<h3></h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
{soilTypeStats.map((stat) => (
<Card key={stat.key} className="p-4 hover:scale-105 hover:shadow-md hover:shadow-gray-300 dark:hover:shadow-gray-500 transition-all duration-300 ease-in-out bg-background">
<div className="text-center">
<div
className="w-3 h-3 rounded-full mx-auto mb-2"
style={{ backgroundColor: stat.color }}
/>
<div className="text-2xl mb-2 text-green-600 dark:text-green-400">{stat.count}</div>
<div className="text-sm text-muted-foreground">{stat.name}</div>
</div>
</Card>
))}
</div>
</Card>
{/* 种植模式统计 */}
<Card className="p-6 bg-card">
<div className="flex items-center gap-2 mb-4">
<Layers className="w-5 h-5 text-blue-600" />
<h3></h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{plantingModeStats.map((stat) => (
<Card key={stat.key} className="p-4 hover:scale-105 hover:shadow-md hover:shadow-gray-300 dark:hover:shadow-gray-500 transition-all duration-300 ease-in-out bg-background">
<div className="text-center">
<div className="text-3xl mb-2">{stat.emoji}</div>
<div className="text-2xl mb-1 text-blue-600 dark:text-blue-400">{stat.count}</div>
<div className="text-sm text-muted-foreground">{stat.name}</div>
</div>
</Card>
))}
</div>
</Card>
{/* 标签统计 */}
<Card className="p-6 bg-card">
<div className="flex items-center gap-2 mb-4">
<Tag className="w-5 h-5 text-purple-600" />
<h3></h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{tagStats.map(({ tag, count }) => (
<Card key={tag.id} className="p-4 hover:scale-105 hover:shadow-md hover:shadow-gray-300 dark:hover:shadow-gray-500 transition-all duration-300 ease-in-out bg-background">
<div className="space-y-2">
<Badge style={{ backgroundColor: tag.color, color: 'white' }} className="w-full justify-center">
{tag.name}
</Badge>
<div className="text-center">
<div className="text-2xl text-purple-600 dark:text-purple-400">{count}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
</Card>
))}
</div>
</Card>
{/* 综合统计 */}
<Card className="p-6 bg-card">
<div className="flex items-center gap-2 mb-4">
<BarChart3 className="w-5 h-5 text-orange-600" />
<h3></h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 bg-green-50 dark:bg-green-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-green-600 dark:text-green-400">{state.fields.length}</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-blue-600 dark:text-blue-400">
{state.fields.reduce((sum, f) => sum + f.area, 0).toFixed(2)}
</div>
</Card>
<Card className="p-4 bg-purple-50 dark:bg-purple-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-purple-600 dark:text-purple-400">{Object.keys(soilTypeStats).length}</div>
</Card>
<Card className="p-4 bg-orange-50 dark:bg-orange-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-orange-600 dark:text-orange-400">{Object.keys(plantingModeStats).length}</div>
</Card>
</div>
</Card> </Card>
{/* 标签管理对话框 */}
<Dialog open={state.showTagManagement} onOpenChange={(open) => dispatch({ type: 'SET_SHOW_TAG_MANAGEMENT', payload: open })}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<LandTagManagement
tags={state.tags}
onSave={handleSaveTag}
onDelete={handleDeleteTag}
/>
</DialogContent>
</Dialog>
{/* 分类管理对话框 */}
<Dialog open={state.showClassificationManagement} onOpenChange={handleCloseClassificationDialog}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<LandClassificationManagement />
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -0,0 +1,174 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import { Filter, Search, Trash2 } from 'lucide-react';
import {
FilterCondition,
SoilType,
PlantingMode,
LandTag
} from './landStatisticsReducer';
interface FilterPanelProps {
filters: FilterCondition;
soilTypes: SoilType[];
plantingModes: PlantingMode[];
tags: LandTag[];
onFilterChange: (key: keyof FilterCondition, value: any) => void;
onToggleArrayFilter: (key: 'soilTypes' | 'plantingModes' | 'tags', value: string) => void;
onClearFilters: () => void;
onExecuteQuery: () => void;
}
export function FilterPanel({
filters,
soilTypes,
plantingModes,
tags,
onFilterChange,
onToggleArrayFilter,
onClearFilters,
onExecuteQuery
}: FilterPanelProps) {
return (
<Card className="p-6 bg-card">
<div className="flex items-center gap-2 mb-4">
<Filter className="w-5 h-5 text-green-600" />
<h3></h3>
</div>
<div className="space-y-6">
{/* 关键词搜索 */}
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索地块名称、编号或位置..."
value={filters.keyword}
onChange={(e) => onFilterChange('keyword', e.target.value)}
className="pl-10 bg-background"
/>
</div>
</div>
{/* 土壤类型 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{soilTypes.map((type) => (
<Badge
key={type.id}
variant={filters.soilTypes.includes(type.key) ? 'default' : 'outline'}
className="cursor-pointer font-light"
style={{
backgroundColor: filters.soilTypes.includes(type.key) ? type.color : 'transparent',
borderColor: type.color,
color: filters.soilTypes.includes(type.key) ? 'white' : type.color,
}}
onClick={() => onToggleArrayFilter('soilTypes', type.key)}
>
<div
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: type.color }}
/>
{type.name}
</Badge>
))}
</div>
</div>
{/* 种植模式 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{plantingModes.map((mode) => (
<Badge
key={mode.id}
variant={filters.plantingModes.includes(mode.key) ? 'default' : 'outline'}
className="cursor-pointer bg-green-600 hover:bg-green-700 font-light"
style={{
backgroundColor: filters.plantingModes.includes(mode.key) ? '#16a34a' : 'transparent',
borderColor: '#16a34a',
color: filters.plantingModes.includes(mode.key) ? 'white' : '#16a34a',
}}
onClick={() => onToggleArrayFilter('plantingModes', mode.key)}
>
<span className="mr-1">{mode.emoji}</span>
{mode.name}
</Badge>
))}
</div>
</div>
{/* 标签 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge
key={tag.id}
variant={filters.tags.includes(tag.name) ? 'default' : 'outline'}
className="cursor-pointer font-light"
style={{
backgroundColor: filters.tags.includes(tag.name) ? tag.color : 'transparent',
borderColor: tag.color,
color: filters.tags.includes(tag.name) ? 'white' : tag.color,
}}
onClick={() => onToggleArrayFilter('tags', tag.name)}
>
{tag.name}
</Badge>
))}
</div>
</div>
{/* 面积范围 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
placeholder="如50"
value={filters.minArea}
onChange={(e) => onFilterChange('minArea', e.target.value)}
className="bg-background"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
placeholder="如200"
value={filters.maxArea}
onChange={(e) => onFilterChange('maxArea', e.target.value)}
className="bg-background"
/>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-2 pt-4">
<Button
className="bg-green-600 hover:bg-green-700 flex-1"
onClick={onExecuteQuery}
>
<Search className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
onClick={onClearFilters}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BarChart3, Download, BarChart, PieChart } from 'lucide-react';
import { StatisticsResult } from './landStatisticsReducer';
import {
BarChart as RechartsBarChart,
Bar,
PieChart as RechartsPieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
const COLORS = ['#22c55e', '#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6', '#f97316', '#8b5cf6'];
interface StatisticsResultsProps {
statistics: StatisticsResult;
chartType: 'bar' | 'pie';
onChartTypeChange: (type: 'bar' | 'pie') => void;
onExportData: () => void;
}
export function StatisticsResults({
statistics,
chartType,
onChartTypeChange,
onExportData
}: StatisticsResultsProps) {
return (
<>
{/* 基础统计 */}
<Card className="p-6 bg-card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-green-600" />
<h3></h3>
</div>
<Button variant="outline" onClick={onExportData}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card className="p-4 bg-green-50 dark:bg-green-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-green-600 dark:text-green-400">{statistics.totalCount}</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-blue-600 dark:text-blue-400">{statistics.totalArea.toFixed(2)} </div>
</Card>
<Card className="p-4 bg-purple-50 dark:bg-purple-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-purple-600 dark:text-purple-400">{statistics.avgArea.toFixed(2)} </div>
</Card>
<Card className="p-4 bg-orange-50 dark:bg-orange-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-orange-600 dark:text-orange-400">{statistics.maxArea.toFixed(2)} </div>
</Card>
<Card className="p-4 bg-pink-50 dark:bg-pink-950">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl text-pink-600 dark:text-pink-400">{statistics.minArea.toFixed(2)} </div>
</Card>
</div>
</Card>
{/* 图表选择 */}
<div className="flex items-center gap-2">
<Button
variant={chartType === 'bar' ? 'default' : 'outline'}
onClick={() => onChartTypeChange('bar')}
className={chartType === 'bar' ? 'bg-green-600 hover:bg-green-700' : ''}
>
<BarChart className="w-4 h-4 mr-2" />
</Button>
<Button
variant={chartType === 'pie' ? 'default' : 'outline'}
onClick={() => onChartTypeChange('pie')}
className={chartType === 'pie' ? 'bg-green-600 hover:bg-green-700' : ''}
>
<PieChart className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 土壤类型分布 */}
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
{chartType === 'bar' ? (
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart data={statistics.soilTypeDistribution}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" name="地块数量" fill="#22c55e" />
<Bar dataKey="area" name="总面积(亩)" fill="#3b82f6" />
</RechartsBarChart>
</ResponsiveContainer>
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.soilTypeDistribution}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.soilTypeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.soilTypeDistribution}
dataKey="area"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.soilTypeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
</div>
)}
</Card>
{/* 种植模式分布 */}
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
{chartType === 'bar' ? (
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart data={statistics.plantingModeDistribution}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" name="地块数量" fill="#a855f7" />
<Bar dataKey="area" name="总面积(亩)" fill="#f59e0b" />
</RechartsBarChart>
</ResponsiveContainer>
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.plantingModeDistribution}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.plantingModeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.plantingModeDistribution}
dataKey="area"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.plantingModeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
</div>
)}
</Card>
{/* 标签分布 */}
{statistics.tagDistribution.length > 0 && (
<Card className="p-6 bg-card">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statistics.tagDistribution.map((tag) => (
<Card key={tag.name} className="p-4 bg-background">
<Badge
style={{ backgroundColor: tag.color, color: 'white' }}
className="w-full justify-center mb-2"
>
{tag.name}
</Badge>
<div className="text-center">
<div className="text-2xl text-green-600 dark:text-green-400">{tag.count}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</Card>
))}
</div>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { Card } from '@/components/ui/card';
export function UsageExamples() {
return (
<Card className="p-6 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<h3 className="mb-2">💡 使</h3>
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
<li> 50"沙土"50</li>
<li> "露地""有机种植"</li>
<li> 50-100"大棚"50-100</li>
<li> </li>
</ul>
</Card>
);
}

View File

@@ -0,0 +1,137 @@
export interface Land {
id: string;
code: string;
name: string;
area: number;
location: string;
soilType: string;
plantingMode: string;
tags: string[];
status: string;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface LandTag {
id: string;
name: string;
color: string;
description?: string;
createdAt: string;
}
export interface SoilType {
id: string;
name: string;
key: string;
color: string;
}
export interface PlantingMode {
id: string;
name: string;
key: string;
emoji: string;
}
export interface FilterCondition {
soilTypes: string[];
plantingModes: string[];
tags: string[];
minArea: string;
maxArea: string;
keyword: string;
}
export interface StatisticsResult {
totalCount: number;
totalArea: number;
avgArea: number;
maxArea: number;
minArea: number;
soilTypeDistribution: { name: string; count: number; area: number; color: string }[];
plantingModeDistribution: { name: string; count: number; area: number; emoji: string }[];
tagDistribution: { name: string; count: number; color: string }[];
}
export interface LandStatisticsState {
fields: Land[];
tags: LandTag[];
soilTypes: SoilType[];
plantingModes: PlantingMode[];
filters: FilterCondition;
statistics: StatisticsResult | null;
chartType: 'bar' | 'pie';
}
export const initialState: LandStatisticsState = {
fields: [],
tags: [],
soilTypes: [],
plantingModes: [],
filters: {
soilTypes: [],
plantingModes: [],
tags: [],
minArea: '',
maxArea: '',
keyword: '',
},
statistics: null,
chartType: 'bar',
};
export type LandStatisticsAction =
| { type: 'SET_FIELDS'; payload: Land[] }
| { type: 'SET_TAGS'; payload: LandTag[] }
| { type: 'SET_SOIL_TYPES'; payload: SoilType[] }
| { type: 'SET_PLANTING_MODES'; payload: PlantingMode[] }
| { type: 'SET_FILTERS'; payload: FilterCondition }
| { type: 'UPDATE_FILTER'; payload: { key: keyof FilterCondition; value: any } }
| { type: 'TOGGLE_ARRAY_FILTER'; payload: { key: 'soilTypes' | 'plantingModes' | 'tags'; value: string } }
| { type: 'CLEAR_FILTERS' }
| { type: 'SET_STATISTICS'; payload: StatisticsResult | null }
| { type: 'SET_CHART_TYPE'; payload: 'bar' | 'pie' };
export function LandStatisticsReducer(state: LandStatisticsState, action: LandStatisticsAction): LandStatisticsState {
switch (action.type) {
case 'SET_FIELDS':
return { ...state, fields: action.payload };
case 'SET_TAGS':
return { ...state, tags: action.payload };
case 'SET_SOIL_TYPES':
return { ...state, soilTypes: action.payload };
case 'SET_PLANTING_MODES':
return { ...state, plantingModes: action.payload };
case 'SET_FILTERS':
return { ...state, filters: action.payload };
case 'UPDATE_FILTER':
return { ...state, filters: { ...state.filters, [action.payload.key]: action.payload.value } };
case 'TOGGLE_ARRAY_FILTER':
const currentArray = state.filters[action.payload.key];
const newArray = currentArray.includes(action.payload.value)
? currentArray.filter(v => v !== action.payload.value)
: [...currentArray, action.payload.value];
return { ...state, filters: { ...state.filters, [action.payload.key]: newArray } };
case 'CLEAR_FILTERS':
return {
...state,
filters: {
soilTypes: [],
plantingModes: [],
tags: [],
minArea: '',
maxArea: '',
keyword: '',
},
statistics: null
};
case 'SET_STATISTICS':
return { ...state, statistics: action.payload };
case 'SET_CHART_TYPE':
return { ...state, chartType: action.payload };
default:
return state;
}
}

View File

@@ -1,18 +1,427 @@
'use client'; 'use client';
import { Card } from '@/components/ui/card'; import { useReducer, useEffect } from 'react';
import { toast } from 'sonner';
import {
LandStatisticsReducer,
initialState,
FilterCondition,
StatisticsResult
} from './components/landStatisticsReducer';
import { FilterPanel } from './components/FilterPanel';
import { StatisticsResults } from './components/StatisticsResults';
import { UsageExamples } from './components/UsageExamples';
export default function StatisticsPage() { export default function LandStatisticsPage() {
const [state, dispatch] = useReducer(LandStatisticsReducer, initialState);
useEffect(() => {
loadData();
}, []);
const loadData = () => {
// 加载地块数据
const fieldsData = localStorage.getItem('land_archive_data');
if (fieldsData) {
dispatch({ type: 'SET_FIELDS', payload: JSON.parse(fieldsData) });
} else {
// 初始化测试数据 - 包含所有土壤类型和种植模式
const testFields = [
{
id: '1',
code: 'TD001',
name: '东区沙质土试验田',
area: 85.5,
location: '东区1号地块',
soilType: 'sandy',
plantingMode: 'conventional',
tags: ['有机种植', '高产示范', '滴灌设施'],
status: 'active',
description: '东区主要试验地块',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '2',
code: 'TD002',
name: '南区黏质土种植区',
area: 120.8,
location: '南区2号地块',
soilType: 'clay',
plantingMode: 'organic',
tags: ['有机种植', '生态种植', '智能监测'],
status: 'active',
description: '南区有机种植示范区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '3',
code: 'TD003',
name: '西区壤质土生产基地',
area: 95.2,
location: '西区3号地块',
soilType: 'loam',
plantingMode: 'greenhouse',
tags: ['科技示范', '智能监测', '节水灌溉'],
status: 'active',
description: '西区温室生产基地',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '4',
code: 'TD004',
name: '北区泥炭土水培区',
area: 45.6,
location: '北区4号地块',
soilType: 'peat',
plantingMode: 'hydroponic',
tags: ['水培种植', '智能监测', '绿色种植'],
status: 'active',
description: '北区水培种植示范区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '5',
code: 'TD005',
name: '中央区石灰质土种植区',
area: 78.9,
location: '中央区5号地块',
soilType: 'chalky',
plantingMode: 'aeroponic',
tags: ['气培种植', '科技示范', '滴灌设施'],
status: 'active',
description: '中央区气培种植试验田',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '6',
code: 'TD006',
name: '东区粉质土生态园',
area: 65.3,
location: '东区6号地块',
soilType: 'silty',
plantingMode: 'organic',
tags: ['生态种植', '绿色种植', '节水灌溉'],
status: 'active',
description: '东区生态循环种植园',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '7',
code: 'TD007',
name: '南区岩石土改良区',
area: 35.7,
location: '南区7号地块',
soilType: 'rocky',
plantingMode: 'conventional',
tags: ['科技示范', '节水灌溉', '高产示范'],
status: 'active',
description: '南区岩石土改良试验田',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '8',
code: 'TD008',
name: '西区沙质土有机基地',
area: 110.4,
location: '西区8号地块',
soilType: 'sandy',
plantingMode: 'organic',
tags: ['有机种植', '绿色种植', '智能监测'],
status: 'active',
description: '西区有机种植基地',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '9',
code: 'TD009',
name: '北区黏质土传统区',
area: 88.6,
location: '北区9号地块',
soilType: 'clay',
plantingMode: 'conventional',
tags: ['传统种植', '滴灌设施', '高产示范'],
status: 'active',
description: '北区传统种植示范区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '10',
code: 'TD010',
name: '中央区壤质土智能园',
area: 92.1,
location: '中央区10号地块',
soilType: 'loam',
plantingMode: 'greenhouse',
tags: ['智能监测', '科技示范', '节水灌溉'],
status: 'active',
description: '中央区智能温室园区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
];
dispatch({ type: 'SET_FIELDS', payload: testFields });
localStorage.setItem('land_archive_data', JSON.stringify(testFields));
}
// 加载标签数据
const tagsData = localStorage.getItem('land_archive_custom_tags');
if (tagsData) {
dispatch({ type: 'SET_TAGS', payload: JSON.parse(tagsData) });
} else {
// 初始化默认标签
const defaultTags = [
{ id: '1', name: '有机种植', color: '#22c55e', description: '符合有机种植标准的地块', createdAt: new Date().toISOString() },
{ id: '2', name: '高产示范', color: '#3b82f6', description: '高产示范田', createdAt: new Date().toISOString() },
{ id: '3', name: '滴灌设施', color: '#f97316', description: '配备滴灌系统的地块', createdAt: new Date().toISOString() },
{ id: '4', name: '智能监测', color: '#a855f7', description: '安装了智能监测设备的地块', createdAt: new Date().toISOString() },
{ id: '5', name: '生态种植', color: '#10b981', description: '采用生态循环种植模式', createdAt: new Date().toISOString() },
{ id: '6', name: '科技示范', color: '#ef4444', description: '农业科技示范地块', createdAt: new Date().toISOString() },
{ id: '7', name: '节水灌溉', color: '#06b6d4', description: '采用节水灌溉技术', createdAt: new Date().toISOString() },
{ id: '8', name: '绿色种植', color: '#84cc16', description: '绿色环保种植方式', createdAt: new Date().toISOString() },
];
dispatch({ type: 'SET_TAGS', payload: defaultTags });
localStorage.setItem('land_archive_custom_tags', JSON.stringify(defaultTags));
}
// 加载土壤类型
const soilTypesData = localStorage.getItem('land_soil_types');
if (soilTypesData) {
dispatch({ type: 'SET_SOIL_TYPES', payload: JSON.parse(soilTypesData) });
} else {
// 初始化默认土壤类型
const defaultSoilTypes = [
{ id: '1', key: 'sandy', name: '沙质土', color: '#f59e0b' },
{ id: '2', key: 'clay', name: '黏质土', color: '#8b5cf6' },
{ id: '3', key: 'loam', name: '壤质土', color: '#22c55e' },
{ id: '4', key: 'peat', name: '泥炭土', color: '#06b6d4' },
{ id: '5', key: 'chalky', name: '石灰质土', color: '#ec4899' },
{ id: '6', key: 'silty', name: '粉质土', color: '#f97316' },
{ id: '7', key: 'rocky', name: '岩石土', color: '#6b7280' }
];
dispatch({ type: 'SET_SOIL_TYPES', payload: defaultSoilTypes });
localStorage.setItem('land_soil_types', JSON.stringify(defaultSoilTypes));
}
// 加载种植模式
const plantingModesData = localStorage.getItem('land_planting_modes');
if (plantingModesData) {
dispatch({ type: 'SET_PLANTING_MODES', payload: JSON.parse(plantingModesData) });
} else {
// 初始化默认种植模式
const defaultPlantingModes = [
{ id: '1', key: 'conventional', name: '传统种植', emoji: '🌾' },
{ id: '2', key: 'organic', name: '有机种植', emoji: '🌱' },
{ id: '3', key: 'greenhouse', name: '温室种植', emoji: '🏠' },
{ id: '4', key: 'hydroponic', name: '水培种植', emoji: '💧' },
{ id: '5', key: 'aeroponic', name: '气培种植', emoji: '☁️' }
];
dispatch({ type: 'SET_PLANTING_MODES', payload: defaultPlantingModes });
localStorage.setItem('land_planting_modes', JSON.stringify(defaultPlantingModes));
}
};
const handleFilterChange = (key: keyof FilterCondition, value: any) => {
dispatch({ type: 'UPDATE_FILTER', payload: { key, value } });
};
const handleToggleArrayFilter = (key: 'soilTypes' | 'plantingModes' | 'tags', value: string) => {
dispatch({ type: 'TOGGLE_ARRAY_FILTER', payload: { key, value } });
};
const handleClearFilters = () => {
dispatch({ type: 'CLEAR_FILTERS' });
toast.success('筛选条件已清空');
};
const executeQuery = () => {
// 应用筛选条件
let filteredFields = [...state.fields];
// 关键词筛选
if (state.filters.keyword) {
const keyword = state.filters.keyword.toLowerCase();
filteredFields = filteredFields.filter(f =>
f.name.toLowerCase().includes(keyword) ||
f.code.toLowerCase().includes(keyword) ||
f.location?.toLowerCase().includes(keyword)
);
}
// 土壤类型筛选
if (state.filters.soilTypes.length > 0) {
filteredFields = filteredFields.filter(f =>
state.filters.soilTypes.includes(f.soilType)
);
}
// 种植模式筛选
if (state.filters.plantingModes.length > 0) {
filteredFields = filteredFields.filter(f =>
state.filters.plantingModes.includes(f.plantingMode)
);
}
// 标签筛选
if (state.filters.tags.length > 0) {
filteredFields = filteredFields.filter(f =>
state.filters.tags.some(tag => f.tags.includes(tag))
);
}
// 面积范围筛选
if (state.filters.minArea) {
const minArea = parseFloat(state.filters.minArea);
filteredFields = filteredFields.filter(f => f.area >= minArea);
}
if (state.filters.maxArea) {
const maxArea = parseFloat(state.filters.maxArea);
filteredFields = filteredFields.filter(f => f.area <= maxArea);
}
if (filteredFields.length === 0) {
toast.warning('未找到符合条件的地块');
dispatch({ type: 'SET_STATISTICS', payload: null });
return;
}
// 计算统计结果
const totalCount = filteredFields.length;
const totalArea = filteredFields.reduce((sum, f) => sum + f.area, 0);
const avgArea = totalArea / totalCount;
const maxArea = Math.max(...filteredFields.map(f => f.area));
const minArea = Math.min(...filteredFields.map(f => f.area));
// 土壤类型分布 - 显示所有定义的土壤类型
const soilTypeDistribution = state.soilTypes.map(soilType => {
const count = filteredFields.filter(f => f.soilType === soilType.key).length;
const area = filteredFields
.filter(f => f.soilType === soilType.key)
.reduce((sum, f) => sum + f.area, 0);
return {
name: soilType.name,
count,
area,
color: soilType.color,
};
});
// 种植模式分布 - 显示所有定义的种植模式
const plantingModeDistribution = state.plantingModes.map(mode => {
const count = filteredFields.filter(f => f.plantingMode === mode.key).length;
const area = filteredFields
.filter(f => f.plantingMode === mode.key)
.reduce((sum, f) => sum + f.area, 0);
return {
name: mode.name,
count,
area,
emoji: mode.emoji,
};
});
// 标签分布
const tagMap = new Map<string, number>();
filteredFields.forEach(f => {
f.tags.forEach(tag => {
tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
});
});
const tagDistribution = Array.from(tagMap.entries()).map(([name, count]) => {
const tag = state.tags.find(t => t.name === name);
return {
name,
count,
color: tag?.color || '#6b7280',
};
}).sort((a, b) => b.count - a.count);
const statisticsResult: StatisticsResult = {
totalCount,
totalArea,
avgArea,
maxArea,
minArea,
soilTypeDistribution,
plantingModeDistribution,
tagDistribution,
};
dispatch({ type: 'SET_STATISTICS', payload: statisticsResult });
toast.success(`查询完成,找到 ${totalCount} 个地块`);
};
const handleChartTypeChange = (type: 'bar' | 'pie') => {
dispatch({ type: 'SET_CHART_TYPE', payload: type });
};
const exportData = () => {
if (!state.statistics) {
toast.error('请先执行查询');
return;
}
const data = {
查询时间: new Date().toLocaleString('zh-CN'),
筛选条件: state.filters,
统计结果: state.statistics,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `地块统计分析_${new Date().getTime()}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('数据导出成功');
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card className="p-6"> <div>
<h2 className="text-xl font-semibold"></h2> <h2 className="text-green-800 dark:text-green-400"></h2>
<div className="p-3 bg-muted rounded-lg mt-3"> <p className="text-muted-foreground">
<p className="text-sm">
<strong></strong> /land-information/archive/statistics </p>
</p> </div>
</div>
</Card> {/* 筛选条件 */}
<FilterPanel
filters={state.filters}
soilTypes={state.soilTypes}
plantingModes={state.plantingModes}
tags={state.tags}
onFilterChange={handleFilterChange}
onToggleArrayFilter={handleToggleArrayFilter}
onClearFilters={handleClearFilters}
onExecuteQuery={executeQuery}
/>
{/* 统计结果 */}
{state.statistics && (
<StatisticsResults
statistics={state.statistics}
chartType={state.chartType}
onChartTypeChange={handleChartTypeChange}
onExportData={exportData}
/>
)}
{/* 使用示例 */}
<UsageExamples />
</div> </div>
); );
} }