Compare commits
2 Commits
df8e6bf515
...
e14f03cf79
| Author | SHA1 | Date | |
|---|---|---|---|
| e14f03cf79 | |||
| 9340252c25 |
427
crop-x/docs/开发项目规范.md
Normal file
427
crop-x/docs/开发项目规范.md
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
# 开发项目规范
|
||||||
|
|
||||||
|
## path:land-information/archive/statistics,name:统计分析页面开发经验
|
||||||
|
|
||||||
|
### 总体开发经验总结
|
||||||
|
|
||||||
|
在实现统计分析页面过程中,我们遵循了以下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数据持久化
|
||||||
|
- 完整的测试数据覆盖所有业务场景
|
||||||
|
- 数据初始化和清理机制完善
|
||||||
|
|
||||||
|
通过遵循这些开发规范,我们可以确保代码的一致性、可维护性和用户体验的统一性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## path:land-information/archive/statistics,name:统计分析页面开发经验与问题解决
|
||||||
|
|
||||||
|
### 问题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. **文档化习惯**:将开发过程中的经验和教训记录下来,形成知识积累
|
||||||
|
- 认识到文档化对团队协作和知识传承的重要性
|
||||||
|
- 建立了完整的开发规范文档体系
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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('数据导出成功');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function StatisticsPage() {
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export function FeatureDescription() {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
|
||||||
|
<h4 className="text-blue-900 dark:text-blue-400 mb-2">✨ GIS地图功能特性</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
|
||||||
|
<span>支持多种地图底图(卫星、电子、地形、混合)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
|
||||||
|
<span>实时切换地图图层,无缝过渡</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
|
||||||
|
<span>地图缩放、平移、全屏等基础操作</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
|
||||||
|
<span>比例尺、坐标、图例动态显示</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
|
||||||
|
<span>地块边界自动渲染,支持交互</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
|
||||||
|
<span>点击地块查看详细信息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-blue-300 dark:border-blue-700">
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
<strong>地图引擎:</strong>默认使用 Leaflet + OpenStreetMap(开源免费),也支持切换到高德地图(需配置Key)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { BaseMap, BaseMapRef } from '@/components/shared/BaseMap';
|
||||||
|
import { GISMapEngine, Marker, Polygon } from '@/lib/gisMapEngine';
|
||||||
|
import { MapLayer } from '@/lib/gisMapEngine';
|
||||||
|
import { Field } from './gisMapReducer';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface MapContainerProps {
|
||||||
|
currentLayer: MapLayer;
|
||||||
|
showLegend: boolean;
|
||||||
|
fields: Field[];
|
||||||
|
selectedField: Field | null;
|
||||||
|
onMapReady: (engine: GISMapEngine) => void;
|
||||||
|
onLayerChange: (layer: MapLayer) => void;
|
||||||
|
onFieldSelect: (field: Field) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapContainer({
|
||||||
|
currentLayer,
|
||||||
|
showLegend,
|
||||||
|
fields,
|
||||||
|
selectedField,
|
||||||
|
onMapReady,
|
||||||
|
onLayerChange,
|
||||||
|
onFieldSelect,
|
||||||
|
}: MapContainerProps) {
|
||||||
|
const mapRef = useRef<BaseMapRef>(null);
|
||||||
|
const engineRef = useRef<GISMapEngine | null>(null);
|
||||||
|
|
||||||
|
// 地图就绪回调
|
||||||
|
const handleMapReady = (engine: GISMapEngine) => {
|
||||||
|
engineRef.current = engine;
|
||||||
|
onMapReady(engine);
|
||||||
|
|
||||||
|
// 添加地块多边形
|
||||||
|
fields.forEach(field => {
|
||||||
|
const polygon: Polygon = {
|
||||||
|
id: field.id,
|
||||||
|
path: field.coordinates,
|
||||||
|
fillColor: field.color,
|
||||||
|
strokeColor: field.color,
|
||||||
|
fillOpacity: 0.3,
|
||||||
|
strokeWeight: 2,
|
||||||
|
onClick: () => {
|
||||||
|
onFieldSelect(field);
|
||||||
|
toast.success(`已选择: ${field.name}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
engine.addPolygon(polygon);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加地块中心点标记
|
||||||
|
fields.forEach(field => {
|
||||||
|
const centerLat = field.coordinates.reduce((sum, p) => sum + p.lat, 0) / field.coordinates.length;
|
||||||
|
const centerLng = field.coordinates.reduce((sum, p) => sum + p.lng, 0) / field.coordinates.length;
|
||||||
|
|
||||||
|
const marker: Marker = {
|
||||||
|
id: `marker-${field.id}`,
|
||||||
|
position: { lat: centerLat, lng: centerLng },
|
||||||
|
title: field.name,
|
||||||
|
color: field.color,
|
||||||
|
onClick: () => {
|
||||||
|
onFieldSelect(field);
|
||||||
|
toast.success(`已选择: ${field.name}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
engine.addMarker(marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('地图加载成功,已添加3个地块');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLayerChange = (layer: MapLayer) => {
|
||||||
|
onLayerChange(layer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当选中地块变化时,可以添加高亮效果
|
||||||
|
useEffect(() => {
|
||||||
|
if (engineRef.current && selectedField) {
|
||||||
|
// 这里可以添加选中地块的高亮逻辑
|
||||||
|
console.log('选中地块:', selectedField.name);
|
||||||
|
}
|
||||||
|
}, [selectedField]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseMap
|
||||||
|
ref={mapRef}
|
||||||
|
provider="leaflet"
|
||||||
|
initialCenter={[116.4074, 39.9042]}
|
||||||
|
initialZoom={13}
|
||||||
|
initialLayer={currentLayer}
|
||||||
|
height="600px"
|
||||||
|
showControls={true}
|
||||||
|
showLayerSwitcher={true}
|
||||||
|
showLegend={showLegend}
|
||||||
|
showScale={true}
|
||||||
|
showCoordinates={true}
|
||||||
|
onMapReady={handleMapReady}
|
||||||
|
onLayerChange={handleLayerChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Map, Layers, MapPin, FileJson } from 'lucide-react';
|
||||||
|
import { MapLayer } from '@/lib/gisMapEngine';
|
||||||
|
import { Field } from './gisMapReducer';
|
||||||
|
|
||||||
|
interface MapInfoPanelProps {
|
||||||
|
currentLayer: MapLayer;
|
||||||
|
fields: Field[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapInfoPanel({ currentLayer, fields }: MapInfoPanelProps) {
|
||||||
|
const getLayerName = (layer: MapLayer): string => {
|
||||||
|
const names: Record<MapLayer, string> = {
|
||||||
|
satellite: '卫星影像',
|
||||||
|
street: '电子地图',
|
||||||
|
terrain: '地形图',
|
||||||
|
hybrid: '混合图层',
|
||||||
|
};
|
||||||
|
return names[layer];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4 bg-card hover:bg-muted transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-50 dark:bg-green-950 rounded-lg">
|
||||||
|
<Map className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">地图引擎</div>
|
||||||
|
<div className="mt-1">Leaflet + OSM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-card hover:bg-muted transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded-lg">
|
||||||
|
<Layers className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">当前图层</div>
|
||||||
|
<div className="mt-1">{getLayerName(currentLayer)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-card hover:bg-muted transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-50 dark:bg-purple-950 rounded-lg">
|
||||||
|
<MapPin className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">显示地块</div>
|
||||||
|
<div className="mt-1">{fields.length} 个</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-card hover:bg-muted transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-orange-50 dark:bg-orange-950 rounded-lg">
|
||||||
|
<FileJson className="w-5 h-5 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">总面积</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
{fields.reduce((sum, f) => sum + f.area, 0).toFixed(1)} 亩
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Field } from './gisMapReducer';
|
||||||
|
|
||||||
|
interface SelectedFieldInfoProps {
|
||||||
|
selectedField: Field | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectedFieldInfo({ selectedField, onClose }: SelectedFieldInfoProps) {
|
||||||
|
if (!selectedField) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-6 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-green-800 dark:text-green-400 mb-1">{selectedField.name}</h3>
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: selectedField.color + '20',
|
||||||
|
color: selectedField.color,
|
||||||
|
borderColor: selectedField.color,
|
||||||
|
}}
|
||||||
|
className="border font-light"
|
||||||
|
>
|
||||||
|
{selectedField.plantingMode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">地块面积</span>
|
||||||
|
<div className="mt-1">{selectedField.area} 亩</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">土壤类型</span>
|
||||||
|
<div className="mt-1">{selectedField.soilType}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">种植模式</span>
|
||||||
|
<div className="mt-1">{selectedField.plantingMode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useReducer } from 'react';
|
||||||
|
import { MapLayer } from '@/lib/gisMapEngine';
|
||||||
|
|
||||||
|
// 地块数据接口
|
||||||
|
export interface Field {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
area: number;
|
||||||
|
coordinates: { lng: number; lat: number }[];
|
||||||
|
soilType: string;
|
||||||
|
plantingMode: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIS状态接口
|
||||||
|
export interface GISMapState {
|
||||||
|
mapEngine: any;
|
||||||
|
currentLayer: MapLayer;
|
||||||
|
selectedField: Field | null;
|
||||||
|
showLegend: boolean;
|
||||||
|
fields: Field[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action类型
|
||||||
|
export type GISMapAction =
|
||||||
|
| { type: 'SET_MAP_ENGINE'; payload: any }
|
||||||
|
| { type: 'SET_CURRENT_LAYER'; payload: MapLayer }
|
||||||
|
| { type: 'SET_SELECTED_FIELD'; payload: Field | null }
|
||||||
|
| { type: 'TOGGLE_LEGEND' }
|
||||||
|
| { type: 'SET_FIELDS'; payload: Field[] };
|
||||||
|
|
||||||
|
// 初始状态
|
||||||
|
const initialState: GISMapState = {
|
||||||
|
mapEngine: null,
|
||||||
|
currentLayer: 'satellite',
|
||||||
|
selectedField: null,
|
||||||
|
showLegend: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'field-1',
|
||||||
|
name: '地块A - 露地种植',
|
||||||
|
area: 125.5,
|
||||||
|
coordinates: [
|
||||||
|
{ lng: 116.400, lat: 39.910 },
|
||||||
|
{ lng: 116.420, lat: 39.910 },
|
||||||
|
{ lng: 116.420, lat: 39.900 },
|
||||||
|
{ lng: 116.400, lat: 39.900 },
|
||||||
|
],
|
||||||
|
soilType: '沙土',
|
||||||
|
plantingMode: '露地',
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-2',
|
||||||
|
name: '地块B - 大棚种植',
|
||||||
|
area: 89.3,
|
||||||
|
coordinates: [
|
||||||
|
{ lng: 116.410, lat: 39.895 },
|
||||||
|
{ lng: 116.425, lat: 39.895 },
|
||||||
|
{ lng: 116.425, lat: 39.885 },
|
||||||
|
{ lng: 116.410, lat: 39.885 },
|
||||||
|
],
|
||||||
|
soilType: '壤土',
|
||||||
|
plantingMode: '大棚',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-3',
|
||||||
|
name: '地块C - 果园',
|
||||||
|
area: 156.8,
|
||||||
|
coordinates: [
|
||||||
|
{ lng: 116.395, lat: 39.890 },
|
||||||
|
{ lng: 116.408, lat: 39.890 },
|
||||||
|
{ lng: 116.408, lat: 39.878 },
|
||||||
|
{ lng: 116.395, lat: 39.878 },
|
||||||
|
],
|
||||||
|
soilType: '粘土',
|
||||||
|
plantingMode: '果园',
|
||||||
|
color: '#f97316',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer函数
|
||||||
|
export function gisMapReducer(state: GISMapState, action: GISMapAction): GISMapState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_MAP_ENGINE':
|
||||||
|
return { ...state, mapEngine: action.payload };
|
||||||
|
|
||||||
|
case 'SET_CURRENT_LAYER':
|
||||||
|
return { ...state, currentLayer: action.payload };
|
||||||
|
|
||||||
|
case 'SET_SELECTED_FIELD':
|
||||||
|
return { ...state, selectedField: action.payload };
|
||||||
|
|
||||||
|
case 'TOGGLE_LEGEND':
|
||||||
|
return { ...state, showLegend: !state.showLegend };
|
||||||
|
|
||||||
|
case 'SET_FIELDS':
|
||||||
|
return { ...state, fields: action.payload };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出初始状态和类型
|
||||||
|
export { initialState };
|
||||||
|
export type { GISMapAction, Field, GISMapState };
|
||||||
@@ -1,18 +1,93 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card } from '@/components/ui/card';
|
import { useReducer } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Layers } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
gisMapReducer,
|
||||||
|
initialState,
|
||||||
|
GISMapState,
|
||||||
|
GISMapAction,
|
||||||
|
Field
|
||||||
|
} from './components/gisMapReducer';
|
||||||
|
import { MapContainer } from './components/MapContainer';
|
||||||
|
import { SelectedFieldInfo } from './components/SelectedFieldInfo';
|
||||||
|
import { MapInfoPanel } from './components/MapInfoPanel';
|
||||||
|
import { FeatureDescription } from './components/FeatureDescription';
|
||||||
|
|
||||||
|
export default function GISMapPage() {
|
||||||
|
const [state, dispatch] = useReducer(gisMapReducer, initialState);
|
||||||
|
|
||||||
|
// 地图就绪回调
|
||||||
|
const handleMapReady = (engine: any) => {
|
||||||
|
dispatch({ type: 'SET_MAP_ENGINE', payload: engine });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图层切换
|
||||||
|
const handleLayerChange = (layer: any) => {
|
||||||
|
dispatch({ type: 'SET_CURRENT_LAYER', payload: layer });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 地块选择
|
||||||
|
const handleFieldSelect = (field: Field) => {
|
||||||
|
dispatch({ type: 'SET_SELECTED_FIELD', payload: field });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换图例显示
|
||||||
|
const handleToggleLegend = () => {
|
||||||
|
dispatch({ type: 'TOGGLE_LEGEND' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭选中地块
|
||||||
|
const handleCloseSelectedField = () => {
|
||||||
|
dispatch({ type: 'SET_SELECTED_FIELD', payload: null });
|
||||||
|
};
|
||||||
|
|
||||||
export default function GisPage() {
|
|
||||||
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">地块GIS地图</h2>
|
<div>
|
||||||
<div className="p-3 bg-muted rounded-lg mt-3">
|
<h2 className="text-green-800 dark:text-green-400">GIS地图管理</h2>
|
||||||
<p className="text-sm">
|
<p className="text-muted-foreground">
|
||||||
<strong>页面路径:</strong> /land-information/map/gis
|
集成多种底图的智慧农业GIS地图系统
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleToggleLegend}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4 mr-2" />
|
||||||
|
{state.showLegend ? '隐藏' : '显示'}图例
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 地图组件 */}
|
||||||
|
<MapContainer
|
||||||
|
currentLayer={state.currentLayer}
|
||||||
|
showLegend={state.showLegend}
|
||||||
|
fields={state.fields}
|
||||||
|
selectedField={state.selectedField}
|
||||||
|
onMapReady={handleMapReady}
|
||||||
|
onLayerChange={handleLayerChange}
|
||||||
|
onFieldSelect={handleFieldSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 选中地块信息 */}
|
||||||
|
<SelectedFieldInfo
|
||||||
|
selectedField={state.selectedField}
|
||||||
|
onClose={handleCloseSelectedField}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 地图信息面板 */}
|
||||||
|
<MapInfoPanel
|
||||||
|
currentLayer={state.currentLayer}
|
||||||
|
fields={state.fields}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 功能说明 */}
|
||||||
|
<FeatureDescription />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
388
crop-x/src/components/shared/BaseMap.tsx
Normal file
388
crop-x/src/components/shared/BaseMap.tsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
Layers,
|
||||||
|
Satellite,
|
||||||
|
Grid3x3,
|
||||||
|
Mountain,
|
||||||
|
MapPin,
|
||||||
|
Ruler,
|
||||||
|
X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GISMapEngine,
|
||||||
|
MapProvider,
|
||||||
|
MapLayer,
|
||||||
|
Marker,
|
||||||
|
Polygon,
|
||||||
|
MapPosition
|
||||||
|
} from '@/lib/gisMapEngine';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface BaseMapProps {
|
||||||
|
provider?: MapProvider;
|
||||||
|
initialCenter?: [number, number];
|
||||||
|
initialZoom?: number;
|
||||||
|
initialLayer?: MapLayer;
|
||||||
|
height?: string;
|
||||||
|
showControls?: boolean;
|
||||||
|
showLayerSwitcher?: boolean;
|
||||||
|
showLegend?: boolean;
|
||||||
|
showScale?: boolean;
|
||||||
|
showCoordinates?: boolean;
|
||||||
|
onMapReady?: (mapEngine: GISMapEngine) => void;
|
||||||
|
onLayerChange?: (layer: MapLayer) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseMapRef {
|
||||||
|
getMapEngine: () => GISMapEngine | null;
|
||||||
|
addMarker: (marker: Marker) => void;
|
||||||
|
addPolygon: (polygon: Polygon) => void;
|
||||||
|
setCenter: (position: MapPosition, zoom?: number) => void;
|
||||||
|
setZoom: (zoom: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BaseMap = forwardRef<BaseMapRef, BaseMapProps>(({
|
||||||
|
provider = 'leaflet',
|
||||||
|
initialCenter = [116.4074, 39.9042],
|
||||||
|
initialZoom = 13,
|
||||||
|
initialLayer = 'satellite',
|
||||||
|
height = '600px',
|
||||||
|
showControls = true,
|
||||||
|
showLayerSwitcher = true,
|
||||||
|
showLegend = false,
|
||||||
|
showScale = true,
|
||||||
|
showCoordinates = true,
|
||||||
|
onMapReady,
|
||||||
|
onLayerChange,
|
||||||
|
className = '',
|
||||||
|
}, ref) => {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapEngineRef = useRef<GISMapEngine | null>(null);
|
||||||
|
|
||||||
|
const [mapLayer, setMapLayer] = useState<MapLayer>(initialLayer);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(initialZoom);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [coordinates, setCoordinates] = useState<MapPosition>({
|
||||||
|
lng: initialCenter[0],
|
||||||
|
lat: initialCenter[1],
|
||||||
|
});
|
||||||
|
const [measuring, setMeasuring] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getMapEngine: () => mapEngineRef.current,
|
||||||
|
addMarker: (marker: Marker) => {
|
||||||
|
mapEngineRef.current?.addMarker(marker);
|
||||||
|
},
|
||||||
|
addPolygon: (polygon: Polygon) => {
|
||||||
|
mapEngineRef.current?.addPolygon(polygon);
|
||||||
|
},
|
||||||
|
setCenter: (position: MapPosition, zoom?: number) => {
|
||||||
|
mapEngineRef.current?.setCenter(position, zoom);
|
||||||
|
},
|
||||||
|
setZoom: (zoom: number) => {
|
||||||
|
mapEngineRef.current?.setZoom(zoom);
|
||||||
|
setZoomLevel(zoom);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainerRef.current) return;
|
||||||
|
|
||||||
|
const initMap = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// 初始化地图引擎
|
||||||
|
const mapEngine = new GISMapEngine({
|
||||||
|
provider,
|
||||||
|
container: mapContainerRef.current!,
|
||||||
|
center: initialCenter,
|
||||||
|
zoom: initialZoom,
|
||||||
|
layer: initialLayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
mapEngineRef.current = mapEngine;
|
||||||
|
|
||||||
|
// 通知父组件地图已就绪
|
||||||
|
if (onMapReady) {
|
||||||
|
onMapReady(mapEngine);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('地图初始化失败:', error);
|
||||||
|
} finally {
|
||||||
|
// 延迟设置加载完成,给地图渲染一些时间
|
||||||
|
setTimeout(() => setIsLoading(false), 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initMap();
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
return () => {
|
||||||
|
if (mapEngineRef.current) {
|
||||||
|
mapEngineRef.current.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLayerChange = (layer: string) => {
|
||||||
|
const newLayer = layer as MapLayer;
|
||||||
|
setMapLayer(newLayer);
|
||||||
|
mapEngineRef.current?.setLayer(newLayer);
|
||||||
|
toast.success(`已切换到${getLayerName(newLayer)}`);
|
||||||
|
|
||||||
|
if (onLayerChange) {
|
||||||
|
onLayerChange(newLayer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
const newZoom = Math.min(zoomLevel + 1, 18);
|
||||||
|
setZoomLevel(newZoom);
|
||||||
|
mapEngineRef.current?.setZoom(newZoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
const newZoom = Math.max(zoomLevel - 1, 1);
|
||||||
|
setZoomLevel(newZoom);
|
||||||
|
mapEngineRef.current?.setZoom(newZoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
setIsFullscreen(!isFullscreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLayerName = (layer: MapLayer): string => {
|
||||||
|
const names: Record<MapLayer, string> = {
|
||||||
|
satellite: '卫星影像',
|
||||||
|
street: '电子地图',
|
||||||
|
terrain: '地形图',
|
||||||
|
hybrid: '混合图层',
|
||||||
|
};
|
||||||
|
return names[layer];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLayerIcon = (layer: MapLayer) => {
|
||||||
|
switch (layer) {
|
||||||
|
case 'satellite': return <Satellite className="w-4 h-4" />;
|
||||||
|
case 'street': return <Grid3x3 className="w-4 h-4" />;
|
||||||
|
case 'terrain': return <Mountain className="w-4 h-4" />;
|
||||||
|
case 'hybrid': return <Layers className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<Card className={`overflow-hidden ${isFullscreen ? 'fixed inset-0 z-50' : ''}`}>
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className="relative w-full"
|
||||||
|
style={{ height: isFullscreen ? '100vh' : height }}
|
||||||
|
>
|
||||||
|
{/* 加载提示 */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mb-4"></div>
|
||||||
|
<p className="text-sm text-muted-foreground">正在加载地图...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 顶部工具栏 */}
|
||||||
|
<div className="absolute top-4 left-4 right-4 z-10 flex items-center justify-between gap-2">
|
||||||
|
{/* 图层指示器 */}
|
||||||
|
<Card className="p-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getLayerIcon(mapLayer)}
|
||||||
|
<span className="text-sm">{getLayerName(mapLayer)}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 右侧控制按钮 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 图层切换 */}
|
||||||
|
{showLayerSwitcher && (
|
||||||
|
<Select value={mapLayer} onValueChange={handleLayerChange}>
|
||||||
|
<SelectTrigger className="w-40 bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="satellite">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Satellite className="w-4 h-4" />
|
||||||
|
卫星影像
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="street">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Grid3x3 className="w-4 h-4" />
|
||||||
|
电子地图
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="terrain">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mountain className="w-4 h-4" />
|
||||||
|
地形图
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="hybrid">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
混合图层
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 测距工具 */}
|
||||||
|
{showControls && (
|
||||||
|
<Button
|
||||||
|
variant={measuring ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMeasuring(!measuring)}
|
||||||
|
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur"
|
||||||
|
>
|
||||||
|
<Ruler className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 全屏切换 */}
|
||||||
|
{showControls && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur"
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Maximize className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 全屏模式关闭按钮 */}
|
||||||
|
{isFullscreen && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 缩放控制 */}
|
||||||
|
{showControls && (
|
||||||
|
<div className="absolute top-20 right-4 z-10">
|
||||||
|
<Card className="p-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={zoomLevel >= 18}
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="text-center text-xs text-muted-foreground py-1 px-2">
|
||||||
|
{zoomLevel}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoomLevel <= 1}
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 比例尺 */}
|
||||||
|
{showScale && (
|
||||||
|
<div className="absolute bottom-4 left-4 z-10">
|
||||||
|
<Card className="p-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-24 h-0.5 bg-black dark:bg-white"></div>
|
||||||
|
<span className="text-xs">
|
||||||
|
{Math.round(500 / Math.pow(2, zoomLevel - 13))}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 坐标显示 */}
|
||||||
|
{showCoordinates && (
|
||||||
|
<div className="absolute bottom-4 right-4 z-10">
|
||||||
|
<Card className="px-3 py-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{coordinates.lat.toFixed(4)}°N, {coordinates.lng.toFixed(4)}°E
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图例 */}
|
||||||
|
{showLegend && (
|
||||||
|
<div className="absolute top-20 left-4 z-10">
|
||||||
|
<Card className="p-3 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||||
|
<h4 className="text-sm mb-2">图例</h4>
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-green-500 bg-opacity-30 border-2 border-green-500 rounded"></div>
|
||||||
|
<span>露地种植</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-blue-500 bg-opacity-30 border-2 border-blue-500 rounded"></div>
|
||||||
|
<span>大棚种植</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-orange-500 bg-opacity-30 border-2 border-orange-500 rounded"></div>
|
||||||
|
<span>果园</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 测距提示 */}
|
||||||
|
{measuring && (
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
|
||||||
|
<Card className="p-4 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||||
|
<div className="text-center">
|
||||||
|
<Ruler className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||||
|
<p className="text-sm">点击地图开始测距</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
单击添加节点,双击结束
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BaseMap.displayName = 'BaseMap';
|
||||||
593
crop-x/src/lib/gisMapEngine.ts
Normal file
593
crop-x/src/lib/gisMapEngine.ts
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
/**
|
||||||
|
* GIS地图引擎 - 统一的地图渲染和管理引擎
|
||||||
|
* 支持多种地图服务商和占位模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MapProvider = 'amap' | 'leaflet' | 'placeholder';
|
||||||
|
export type MapLayer = 'satellite' | 'street' | 'terrain' | 'hybrid';
|
||||||
|
|
||||||
|
export interface MapConfig {
|
||||||
|
provider: MapProvider;
|
||||||
|
container: string | HTMLElement;
|
||||||
|
center?: [number, number]; // [lng, lat]
|
||||||
|
zoom?: number;
|
||||||
|
layer?: MapLayer;
|
||||||
|
features?: MapFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapFeature {
|
||||||
|
controls?: {
|
||||||
|
zoom?: boolean;
|
||||||
|
scale?: boolean;
|
||||||
|
layers?: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
measure?: boolean;
|
||||||
|
};
|
||||||
|
interactions?: {
|
||||||
|
drag?: boolean;
|
||||||
|
zoom?: boolean;
|
||||||
|
rotate?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapPosition {
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapBounds {
|
||||||
|
northeast: MapPosition;
|
||||||
|
southwest: MapPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Marker {
|
||||||
|
id: string;
|
||||||
|
position: MapPosition;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Polygon {
|
||||||
|
id: string;
|
||||||
|
path: MapPosition[];
|
||||||
|
fillColor?: string;
|
||||||
|
strokeColor?: string;
|
||||||
|
fillOpacity?: number;
|
||||||
|
strokeWeight?: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GIS地图引擎类
|
||||||
|
*/
|
||||||
|
export class GISMapEngine {
|
||||||
|
private provider: MapProvider;
|
||||||
|
private map: any = null;
|
||||||
|
private markers: Map<string, any> = new Map();
|
||||||
|
private polygons: Map<string, any> = new Map();
|
||||||
|
private currentLayer: MapLayer = 'satellite';
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(config: MapConfig) {
|
||||||
|
this.provider = config.provider;
|
||||||
|
this.initialize(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化地图
|
||||||
|
*/
|
||||||
|
private async initialize(config: MapConfig) {
|
||||||
|
const container = typeof config.container === 'string'
|
||||||
|
? document.getElementById(config.container)
|
||||||
|
: config.container;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
console.error('地图容器不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container = container;
|
||||||
|
|
||||||
|
switch (this.provider) {
|
||||||
|
case 'amap':
|
||||||
|
await this.initAMap(config);
|
||||||
|
break;
|
||||||
|
case 'leaflet':
|
||||||
|
await this.initLeaflet(config);
|
||||||
|
break;
|
||||||
|
case 'placeholder':
|
||||||
|
this.initPlaceholder(config);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化高德地图
|
||||||
|
*/
|
||||||
|
private async initAMap(config: MapConfig) {
|
||||||
|
try {
|
||||||
|
// 检查是否已加载高德地图
|
||||||
|
if (!window.AMap) {
|
||||||
|
console.log('💡 高德地图未配置,使用演示地图模式(功能完整可用)');
|
||||||
|
this.provider = 'placeholder';
|
||||||
|
this.initPlaceholder(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = config.center || [116.4074, 39.9042]; // 默认北京
|
||||||
|
const zoom = config.zoom || 13;
|
||||||
|
|
||||||
|
this.map = new window.AMap.Map(this.container, {
|
||||||
|
center: center,
|
||||||
|
zoom: zoom,
|
||||||
|
viewMode: '2D',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置图层
|
||||||
|
this.setLayer(config.layer || 'satellite');
|
||||||
|
|
||||||
|
console.log('✅ 高德地图初始化成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('高德地图初始化失败:', error);
|
||||||
|
this.provider = 'placeholder';
|
||||||
|
this.initPlaceholder(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化Leaflet地图(使用OpenStreetMap)
|
||||||
|
*/
|
||||||
|
private async initLeaflet(config: MapConfig) {
|
||||||
|
try {
|
||||||
|
console.log('🔄 正在初始化 Leaflet 地图...');
|
||||||
|
|
||||||
|
// 动态加载Leaflet
|
||||||
|
if (!window.L) {
|
||||||
|
console.log('📦 Leaflet 未加载,开始加载...');
|
||||||
|
await this.loadLeaflet();
|
||||||
|
} else {
|
||||||
|
console.log('✅ Leaflet 已存在,跳过加载');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再次检查是否成功加载
|
||||||
|
if (!window.L) {
|
||||||
|
throw new Error('Leaflet 加载失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = config.center || [39.9042, 116.4074]; // Leaflet用 [lat, lng]
|
||||||
|
const zoom = config.zoom || 13;
|
||||||
|
|
||||||
|
this.map = window.L.map(this.container).setView([center[1], center[0]], zoom);
|
||||||
|
|
||||||
|
// 设置图层
|
||||||
|
this.setLayer(config.layer || 'street');
|
||||||
|
|
||||||
|
console.log('✅ Leaflet地图初始化成功');
|
||||||
|
console.log('📍 中心坐标:', center);
|
||||||
|
console.log('🔍 缩放级别:', zoom);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Leaflet地图初始化失败,切换到占位地图模式');
|
||||||
|
console.error('错误详情:', error);
|
||||||
|
this.provider = 'placeholder';
|
||||||
|
this.initPlaceholder(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载Leaflet库
|
||||||
|
*/
|
||||||
|
private async loadLeaflet(): Promise<void> {
|
||||||
|
// 使用统一的 Leaflet 加载器
|
||||||
|
const { preloadLeaflet } = await import('./leafletLoader');
|
||||||
|
const success = await preloadLeaflet();
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('Leaflet加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化占位地图
|
||||||
|
*/
|
||||||
|
private initPlaceholder(config: MapConfig) {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const center = config.center || [116.4074, 39.9042];
|
||||||
|
const zoom = config.zoom || 13;
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="gis-placeholder-map" style="
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #e8f5e9 0%, #e3f2fd 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
">
|
||||||
|
<!-- 网格背景 -->
|
||||||
|
<div style="
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(76, 175, 80, 0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(76, 175, 80, 0.1) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
"></div>
|
||||||
|
|
||||||
|
<!-- 地图信息提示 -->
|
||||||
|
<div style="
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 24px 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" style="margin: 0 auto 16px;">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
||||||
|
<circle cx="12" cy="10" r="3"></circle>
|
||||||
|
</svg>
|
||||||
|
<h3 style="font-size: 18px; font-weight: 600; color: #1f2937; margin-bottom: 8px;">
|
||||||
|
地图演示模式
|
||||||
|
</h3>
|
||||||
|
<p style="font-size: 14px; color: #6b7280; margin-bottom: 16px;">
|
||||||
|
当前使用占位地图,所有功能正常可用
|
||||||
|
</p>
|
||||||
|
<div style="font-size: 12px; color: #9ca3af; border-top: 1px solid #e5e7eb; padding-top: 12px;">
|
||||||
|
<p style="margin-bottom: 4px;">中心坐标: ${center[0].toFixed(4)}°E, ${center[1].toFixed(4)}°N</p>
|
||||||
|
<p>缩放级别: ${zoom}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 地图图层标签 -->
|
||||||
|
<div style="
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
">
|
||||||
|
${this.getLayerLabel(this.currentLayer)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('✅ 占位地图初始化成功(功能完整)');
|
||||||
|
console.log('💡 提示: 系统可以正常使用,如需真实地图请参考文档配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图层标签
|
||||||
|
*/
|
||||||
|
private getLayerLabel(layer: MapLayer): string {
|
||||||
|
const labels: Record<MapLayer, string> = {
|
||||||
|
satellite: '🛰️ 卫星影像',
|
||||||
|
street: '🗺️ 电子地图',
|
||||||
|
terrain: '⛰️ 地形图',
|
||||||
|
hybrid: '🔀 混合图层',
|
||||||
|
};
|
||||||
|
return labels[layer];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置地图图层
|
||||||
|
*/
|
||||||
|
setLayer(layer: MapLayer) {
|
||||||
|
this.currentLayer = layer;
|
||||||
|
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
// 高德地图图层
|
||||||
|
this.map.setLayers([this.getAMapLayer(layer)]);
|
||||||
|
} else if (this.provider === 'leaflet' && this.map) {
|
||||||
|
// Leaflet图层
|
||||||
|
this.getLeafletLayer(layer).addTo(this.map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取高德地图图层
|
||||||
|
*/
|
||||||
|
private getAMapLayer(layer: MapLayer) {
|
||||||
|
switch (layer) {
|
||||||
|
case 'satellite':
|
||||||
|
return new window.AMap.TileLayer.Satellite();
|
||||||
|
case 'street':
|
||||||
|
return new window.AMap.TileLayer();
|
||||||
|
case 'terrain':
|
||||||
|
return new window.AMap.TileLayer();
|
||||||
|
case 'hybrid':
|
||||||
|
return [
|
||||||
|
new window.AMap.TileLayer.Satellite(),
|
||||||
|
new window.AMap.TileLayer.RoadNet()
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return new window.AMap.TileLayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取Leaflet图层
|
||||||
|
*/
|
||||||
|
private getLeafletLayer(layer: MapLayer) {
|
||||||
|
const baseLayers: Record<MapLayer, string> = {
|
||||||
|
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
street: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
terrain: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||||
|
hybrid: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
};
|
||||||
|
|
||||||
|
return window.L.tileLayer(baseLayers[layer], {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加标记点
|
||||||
|
*/
|
||||||
|
addMarker(marker: Marker) {
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
const amapMarker = new window.AMap.Marker({
|
||||||
|
position: [marker.position.lng, marker.position.lat],
|
||||||
|
title: marker.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (marker.onClick) {
|
||||||
|
amapMarker.on('click', marker.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.map.add(amapMarker);
|
||||||
|
this.markers.set(marker.id, amapMarker);
|
||||||
|
} else if (this.provider === 'leaflet' && this.map) {
|
||||||
|
const leafletMarker = window.L.marker([marker.position.lat, marker.position.lng])
|
||||||
|
.addTo(this.map);
|
||||||
|
|
||||||
|
if (marker.title) {
|
||||||
|
leafletMarker.bindPopup(marker.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marker.onClick) {
|
||||||
|
leafletMarker.on('click', marker.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.markers.set(marker.id, leafletMarker);
|
||||||
|
} else if (this.provider === 'placeholder') {
|
||||||
|
// 占位模式:在容器中添加标记点
|
||||||
|
this.addPlaceholderMarker(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 占位模式添加标记
|
||||||
|
*/
|
||||||
|
private addPlaceholderMarker(marker: Marker) {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const markerEl = document.createElement('div');
|
||||||
|
markerEl.id = `marker-${marker.id}`;
|
||||||
|
markerEl.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
left: ${Math.random() * 80 + 10}%;
|
||||||
|
top: ${Math.random() * 80 + 10}%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: ${marker.color || '#22c55e'};
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (marker.onClick) {
|
||||||
|
markerEl.addEventListener('click', marker.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.querySelector('.gis-placeholder-map')?.appendChild(markerEl);
|
||||||
|
this.markers.set(marker.id, markerEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加多边形
|
||||||
|
*/
|
||||||
|
addPolygon(polygon: Polygon) {
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
const amapPolygon = new window.AMap.Polygon({
|
||||||
|
path: polygon.path.map(p => [p.lng, p.lat]),
|
||||||
|
fillColor: polygon.fillColor || '#22c55e',
|
||||||
|
strokeColor: polygon.strokeColor || '#166534',
|
||||||
|
fillOpacity: polygon.fillOpacity || 0.3,
|
||||||
|
strokeWeight: polygon.strokeWeight || 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (polygon.onClick) {
|
||||||
|
amapPolygon.on('click', polygon.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.map.add(amapPolygon);
|
||||||
|
this.polygons.set(polygon.id, amapPolygon);
|
||||||
|
} else if (this.provider === 'leaflet' && this.map) {
|
||||||
|
const leafletPolygon = window.L.polygon(
|
||||||
|
polygon.path.map(p => [p.lat, p.lng]),
|
||||||
|
{
|
||||||
|
color: polygon.strokeColor || '#166534',
|
||||||
|
fillColor: polygon.fillColor || '#22c55e',
|
||||||
|
fillOpacity: polygon.fillOpacity || 0.3,
|
||||||
|
weight: polygon.strokeWeight || 2,
|
||||||
|
}
|
||||||
|
).addTo(this.map);
|
||||||
|
|
||||||
|
if (polygon.onClick) {
|
||||||
|
leafletPolygon.on('click', polygon.onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.polygons.set(polygon.id, leafletPolygon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除标记
|
||||||
|
*/
|
||||||
|
removeMarker(id: string) {
|
||||||
|
const marker = this.markers.get(id);
|
||||||
|
if (!marker) return;
|
||||||
|
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
this.map.remove(marker);
|
||||||
|
} else if (this.provider === 'leaflet') {
|
||||||
|
marker.remove();
|
||||||
|
} else if (this.provider === 'placeholder') {
|
||||||
|
marker.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.markers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除多边形
|
||||||
|
*/
|
||||||
|
removePolygon(id: string) {
|
||||||
|
const polygon = this.polygons.get(id);
|
||||||
|
if (!polygon) return;
|
||||||
|
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
this.map.remove(polygon);
|
||||||
|
} else if (this.provider === 'leaflet') {
|
||||||
|
polygon.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.polygons.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置中心点
|
||||||
|
*/
|
||||||
|
setCenter(position: MapPosition, zoom?: number) {
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
this.map.setCenter([position.lng, position.lat]);
|
||||||
|
if (zoom) this.map.setZoom(zoom);
|
||||||
|
} else if (this.provider === 'leaflet' && this.map) {
|
||||||
|
this.map.setView([position.lat, position.lng], zoom || this.map.getZoom());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 适应边界
|
||||||
|
*/
|
||||||
|
fitBounds(bounds: MapBounds) {
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
this.map.setBounds(
|
||||||
|
new window.AMap.Bounds(
|
||||||
|
[bounds.southwest.lng, bounds.southwest.lat],
|
||||||
|
[bounds.northeast.lng, bounds.northeast.lat]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (this.provider === 'leaflet' && this.map) {
|
||||||
|
this.map.fitBounds([
|
||||||
|
[bounds.southwest.lat, bounds.southwest.lng],
|
||||||
|
[bounds.northeast.lat, bounds.northeast.lng]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩放
|
||||||
|
*/
|
||||||
|
setZoom(zoom: number) {
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
this.map.setZoom(zoom);
|
||||||
|
} else if (this.provider === 'leaflet' && this.map) {
|
||||||
|
this.map.setZoom(zoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前缩放级别
|
||||||
|
*/
|
||||||
|
getZoom(): number {
|
||||||
|
if (this.provider === 'amap' && this.map) {
|
||||||
|
return this.map.getZoom();
|
||||||
|
} else if (this.provider === 'leaflet' && this.map) {
|
||||||
|
return this.map.getZoom();
|
||||||
|
}
|
||||||
|
return 13; // 默认缩放
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有标记
|
||||||
|
*/
|
||||||
|
clearMarkers() {
|
||||||
|
this.markers.forEach((marker, id) => {
|
||||||
|
this.removeMarker(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有多边形
|
||||||
|
*/
|
||||||
|
clearPolygons() {
|
||||||
|
this.polygons.forEach((polygon, id) => {
|
||||||
|
this.removePolygon(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有覆盖物
|
||||||
|
*/
|
||||||
|
clearAll() {
|
||||||
|
this.clearMarkers();
|
||||||
|
this.clearPolygons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁地图
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.clearAll();
|
||||||
|
|
||||||
|
if (this.map) {
|
||||||
|
if (this.provider === 'amap') {
|
||||||
|
this.map.destroy();
|
||||||
|
} else if (this.provider === 'leaflet') {
|
||||||
|
this.map.remove();
|
||||||
|
}
|
||||||
|
this.map = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.container) {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取地图实例
|
||||||
|
*/
|
||||||
|
getMapInstance() {
|
||||||
|
return this.map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前提供商
|
||||||
|
*/
|
||||||
|
getProvider(): MapProvider {
|
||||||
|
return this.provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局类型声明
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
AMap: any;
|
||||||
|
L: any;
|
||||||
|
_AMapSecurityConfig: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
crop-x/src/lib/leafletLoader.ts
Normal file
98
crop-x/src/lib/leafletLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Leaflet 地图库预加载器
|
||||||
|
* 确保 Leaflet 在需要时已经加载完成
|
||||||
|
*/
|
||||||
|
|
||||||
|
let leafletLoading = false;
|
||||||
|
let leafletLoaded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载 Leaflet 库
|
||||||
|
* @returns Promise<boolean> 加载成功返回 true
|
||||||
|
*/
|
||||||
|
export const preloadLeaflet = (): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// 如果已经加载,直接返回
|
||||||
|
if (leafletLoaded || window.L) {
|
||||||
|
leafletLoaded = true;
|
||||||
|
console.log('✅ Leaflet 已加载');
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在加载,等待加载完成
|
||||||
|
if (leafletLoading) {
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (leafletLoaded || window.L) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
leafletLoaded = true;
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
leafletLoading = true;
|
||||||
|
console.log('🔄 开始加载 Leaflet...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 加载 CSS
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||||
|
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||||
|
link.crossOrigin = '';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
// 加载 JS
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||||
|
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
|
||||||
|
script.crossOrigin = '';
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
leafletLoaded = true;
|
||||||
|
leafletLoading = false;
|
||||||
|
console.log('✅ Leaflet 加载成功');
|
||||||
|
console.log('📍 版本:', window.L?.version);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
leafletLoading = false;
|
||||||
|
console.warn('⚠️ Leaflet 加载失败,将使用占位地图');
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} catch (error) {
|
||||||
|
leafletLoading = false;
|
||||||
|
console.error('❌ 加载 Leaflet 时发生错误:', error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Leaflet 是否已加载
|
||||||
|
*/
|
||||||
|
export const isLeafletLoaded = (): boolean => {
|
||||||
|
return leafletLoaded || !!window.L;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Leaflet 版本
|
||||||
|
*/
|
||||||
|
export const getLeafletVersion = (): string | null => {
|
||||||
|
if (isLeafletLoaded() && window.L) {
|
||||||
|
return window.L.version || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 扩展 Window 接口
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
L: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user