生产管理系统前端 - 数字化绘制与编辑

This commit is contained in:
2025-10-29 16:57:06 +08:00
parent e14f03cf79
commit 3239f819d0
8 changed files with 2844 additions and 7 deletions

View File

@@ -424,4 +424,343 @@ const handleToggleArrayFilter = (key: 'soilTypes' | 'plantingModes' | 'tags', va
5. **文档化习惯**:将开发过程中的经验和教训记录下来,形成知识积累
- 认识到文档化对团队协作和知识传承的重要性
- 建立了完整的开发规范文档体系
- 建立了完整的开发规范文档体系
---
## pathland-information/map/gisnameGIS地图管理开发经验与问题解决
### 总体开发经验总结
GIS地图管理页面的开发过程是一个复杂的技术集成挑战涉及到第三方地图库的集成、异步资源加载、多层级组件交互等多个技术难点。通过这次开发我们建立了一套完整的GIS应用开发模式特别是在处理真实地图数据源和优雅降级方面积累了宝贵经验。
### 问题1地图组件初始化时缺少真实地图数据源
**问题描述:**
- 初始实现的BaseMap组件只是简单的模拟展示无法加载真实的卫星图像
- 用户反馈参考文件可以看到真实的卫星图,但当前页面只显示占位符
- 缺乏对真实地图服务商的集成支持
**原始需求分析:**
- 需要支持真实的卫星影像显示,而不是简单的占位地图
- 必须支持多种地图图层切换(卫星、电子、地形、混合)
- 需要完整的地图交互功能,包括缩放、平移、全屏等
**解决方案:**
- 复制完整的GISMapEngine实现支持多种地图提供商
- 实现leafletLoader动态加载器支持异步加载地图库
- 建立真实地图瓦片数据源连接包括ArcGIS卫星影像和OpenStreetMap
**代码实现对比:**
```tsx
// ❌ 初始简化实现
export class GISMapEngine {
constructor(map: any) {
this.map = map; // 只是一个模拟对象
}
addPolygon(polygon: MapPolygon): void {
this.polygons.set(polygon.id, polygon); // 没有真实渲染
}
}
// ✅ 最终完整实现
export class GISMapEngine {
constructor(config: MapConfig) {
this.provider = config.provider;
this.initialize(config); // 真实初始化流程
}
private async initLeaflet(config: MapConfig) {
// 动态加载Leaflet库
if (!window.L) {
await this.loadLeaflet();
}
// 创建真实地图实例
this.map = window.L.map(this.container).setView([center[1], center[0]], zoom);
// 设置真实瓦片图层
this.getLeafletLayer(layer).addTo(this.map);
}
}
```
### 问题2地图库异步加载和依赖管理复杂性
**问题描述:**
- 地图库Leaflet需要从CDN异步加载存在加载失败风险
- 地图组件需要在库加载完成后才能初始化,存在时序问题
- 多个地图组件可能重复加载同一资源,造成性能浪费
**原始需求分析:**
- 确保地图库能够可靠加载,提供良好的用户体验
- 处理加载失败的情况,提供优雅的降级方案
- 优化资源加载性能,避免重复加载
**解决方案:**
- 创建leafletLoader统一管理地图库的加载过程
- 实现加载状态管理和重试机制
- 建立全局加载状态缓存,避免重复加载
**关键实现代码:**
```tsx
// leafletLoader.ts - 统一加载管理
export const preloadLeaflet = (): Promise<boolean> => {
return new Promise((resolve) => {
if (leafletLoaded || window.L) {
resolve(true);
return;
}
if (leafletLoading) {
// 等待正在进行的加载完成
const checkInterval = setInterval(() => {
if (leafletLoaded || window.L) {
clearInterval(checkInterval);
resolve(true);
}
}, 100);
return;
}
// 执行实际加载过程
const script = document.createElement('script');
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.onload = () => { leafletLoaded = true; resolve(true); };
script.onerror = () => { resolve(false); };
document.head.appendChild(script);
});
};
```
### 问题3地图组件与状态管理的深度集成
**问题描述:**
- 地图组件需要与useReducer状态管理深度集成
- 地图的异步初始化过程与React生命周期存在冲突
- 地图事件回调与状态更新的时序同步问题
**原始需求分析:**
- 地图操作需要能够更新全局状态(如选中地块、图层切换)
- 状态变化需要能够反映到地图显示上
- 需要处理地图组件的清理和资源释放
**解决方案:**
- 使用useImperativeHandle暴露地图实例方法给父组件
- 实现地图引擎的引用管理,确保状态同步
- 建立完整的生命周期管理,包括组件卸载时的资源清理
**状态管理集成代码:**
```tsx
// BaseMap组件中的状态集成
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);
},
}));
// 组件卸载时的资源清理
useEffect(() => {
return () => {
if (mapEngineRef.current) {
mapEngineRef.current.destroy();
}
};
}, []);
```
### 问题4真实地图数据源的集成和配置
**问题描述:**
- 需要集成多种真实的地图瓦片数据源
- 不同地图服务商的API格式和坐标系统存在差异
- 需要处理地图瓦片的加载性能和缓存策略
**原始需求分析:**
- 提供真实的卫星影像、电子地图、地形图等多种图层
- 确保地图数据的准确性和时效性
- 优化地图加载性能,提供流畅的用户体验
**解决方案:**
- 集成多个开源地图数据源,确保服务的可靠性
- 统一不同数据源的坐标系统和API格式
- 实现智能的图层切换和缓存机制
**地图数据源配置:**
```tsx
private getLeafletLayer(layer: MapLayer) {
const baseLayers: Record<MapLayer, string> = {
// ArcGIS卫星影像 - 真实卫星图
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
// OpenStreetMap - 开源电子地图
street: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
// OpenTopoMap - 开源地形图
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',
maxZoom: 18,
});
}
```
### 问题5地图交互功能的完整实现
**问题描述:**
- 需要实现地块多边形的渲染和交互
- 地图标记点的添加和点击事件处理
- 地图控件(缩放、图层切换、全屏等)的集成
**原始需求分析:**
- 地块需要在地图上以彩色多边形形式显示
- 点击地块需要触发选择事件和状态更新
- 提供完整的地图导航和操作功能
**解决方案:**
- 使用地图引擎的Polygon和Marker API实现地块渲染
- 建立事件处理机制,连接地图交互和状态管理
- 集成完整的地图控件套件,提供专业级用户体验
**交互功能实现:**
```tsx
// 地块多边形渲染
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);
// 地块标记点渲染
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);
```
## 开发经验对比总结
### 与原始要求的差异分析
| 原始要求 | 实际实现 | 差异说明 | 解决过程 |
|---------|---------|---------|---------|
| 1:1还原地图功能 | 完整实现 + 真实数据源 | 需要集成真实地图服务商和瓦片数据 | 建立完整的地图引擎架构,支持多种数据源 |
| 第三方库集成 | 专业级集成 | 需要处理异步加载、错误处理、性能优化 | 实现统一加载器和优雅降级机制 |
| 组件状态管理 | 深度集成 + 生命周期管理 | 地图组件与React状态系统需要深度集成 | 使用useImperativeHandle和引用管理 |
| 交互功能实现 | 完整交互套件 | 需要实现多边形、标记、控件等完整功能 | 集成地图引擎API建立事件处理机制 |
### 关键学习点和改进
1. **第三方库集成思维**学会了如何可靠地集成和管理复杂的第三方JavaScript库
- 掌握了异步加载、错误处理、优雅降级的完整流程
- 理解了库版本管理和兼容性处理的重要性
2. **地图API应用经验**深入了解了Web地图开发的技术栈和最佳实践
- 学会了瓦片地图的原理和多种数据源的使用
- 掌握了地图交互事件的处理和状态同步机制
3. **React高级模式应用**在复杂组件中应用了useImperativeHandle、useRef等高级React模式
- 深入理解了React组件的暴露方法和引用传递机制
- 掌握了复杂组件生命周期管理的最佳实践
4. **性能优化意识**:建立了地图应用的性能优化思维
- 学会了资源懒加载和缓存策略的设计
- 理解了大型第三方库对应用性能的影响和优化方法
5. **用户体验设计**:在技术实现中始终考虑用户体验
- 建立了加载状态和错误处理的设计模式
- 掌握了优雅降级和渐进增强的实现方法
6. **架构设计能力**:设计了可扩展的地图应用架构
- 建立了插件化的地图引擎设计
---
## pathsrc/app/(app)/land-information/map/drawname数字化绘制与编辑页面开发经验
### 1. **复杂状态管理设计**
- **useReducer 模式应用**:使用 useReducer 管理复杂的编辑状态,包含多个布尔状态、数组和对象
- **状态结构设计**:设计了包含高级编辑器状态、活动标签页、地块数据、保存对话框等的状态结构
- **Action 设计模式**:采用类型安全的 Action 设计,支持状态更新、字段管理、对话框控制等操作
### 2. **组件化架构设计**
- **模块化组件结构**将复杂功能拆分为6个独立组件每个组件负责单一职责
- `drawEditReducer.tsx`:状态管理核心
- `DrawingTools.tsx`:绘制工具组件
- `EditingTools.tsx`:编辑工具组件
- `FieldEntryDialog.tsx`:地块信息录入对话框
- `UsageGuide.tsx`:使用指南组件
- `AdvancedEditorPromo.tsx`:高级编辑器推广组件
- **组件通信设计**:通过 props 和回调函数实现组件间的数据传递和事件处理
### 3. **Canvas 绘图技术实现**
- **多种绘制模式**:实现点、线、多边形、矩形等多种绘制模式
- **实时交互反馈**:支持鼠标移动吸附、节点高亮、实时预览等功能
- **几何计算算法**
- Shoelace 公式计算多边形面积
- 坐标距离计算周长
- 点在多边形内判断算法
- 自相交检测算法
### 4. **高级编辑功能实现**
- **节点编辑**:支持拖拽节点、添加节点、删除节点
- **地块分割**:绘制分割线将地块分成两部分,支持垂直和水平分割
- **地块合并**:多地块选择和凸包算法合并
- **历史记录管理**:实现撤销/重做功能,支持操作历史追踪
### 5. **用户体验设计**
- **分步骤操作引导**:为复杂操作提供详细的操作步骤说明
- **实时状态反馈**Toast 通知、状态栏显示、操作确认等
- **键盘快捷键支持**Ctrl+Z 撤销、Ctrl+S 保存、Delete 清除、Esc 取消
- **视觉状态管理**:选中高亮、禁用状态、加载状态等
### 6. **数据管理与持久化**
- **表单验证设计**:完整的表单验证逻辑,支持必填项检查和格式验证
- **本地存储集成**:与 localStorage 集成,支持地块数据的持久化
- **自动数据生成**:地块编号、名称的自动生成逻辑
- **标签管理功能**:支持标签的添加、删除和展示
### 7. **技术规范遵循**
- **shadcn/ui 语义样式**:使用 `bg-card``bg-muted``text-muted-foreground` 等语义化样式
- **暗色主题支持**:完整支持暗色主题,使用 `dark:` 前缀
- **TypeScript 类型安全**:完整的类型定义,确保类型安全
- **响应式设计**:支持不同屏幕尺寸的适配
### 8. **开发效率提升**
- **组件复用设计**:通用组件可在其他页面复用
- **配置化参数**:画布尺寸、吸附距离等参数可配置
- **错误处理机制**:完善的错误处理和用户提示
- **代码组织结构**:清晰的文件结构和命名规范
### 9. **性能优化考虑**
- **事件处理优化**:使用 useCallback 避免不必要的重渲染
- **状态更新策略**:合理的状态更新时机和批量处理
- **Canvas 渲染优化**:减少不必要的重绘和计算
### 10. **可扩展性设计**
- **插件化架构**:编辑工具采用插件化设计,易于扩展新功能
- **接口标准化**:统一的接口设计,便于功能模块替换
- **配置化开发**:支持通过配置文件调整功能和行为
- 理解了复杂应用中的组件分层和职责划分

View File

@@ -0,0 +1,33 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Sparkles } from 'lucide-react';
interface AdvancedEditorPromoProps {
onSwitchToAdvanced: () => void;
}
export function AdvancedEditorPromo({ onSwitchToAdvanced }: AdvancedEditorPromoProps) {
return (
<Card className="p-4 bg-gradient-to-r from-green-50 dark:from-green-950 to-blue-50 dark:to-blue-950 border-green-200 dark:border-green-800">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<h4 className="text-green-800 dark:text-green-400 mb-1">💡 </h4>
<p className="text-sm text-muted-foreground mb-3">
KML/GeoJSON/SHPGIS应用
</p>
<Button
size="sm"
onClick={onSwitchToAdvanced}
className="bg-green-600 hover:bg-green-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,613 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
MousePointer,
MapPin,
Minus,
Pentagon,
Square,
Circle,
Undo,
Trash2,
Save,
AlertCircle,
CheckCircle2
} from 'lucide-react';
import { toast } from 'sonner';
import { DrawGeometry } from './drawEditReducer';
export type DrawMode = 'none' | 'point' | 'line' | 'polygon' | 'rectangle' | 'circle';
export interface DrawPoint {
x: number;
y: number;
lat?: number;
lng?: number;
}
interface DrawingToolsProps {
onSaveRequest?: (geometry: DrawGeometry) => void;
enableSnapping?: boolean;
snapDistance?: number;
canvasWidth?: number;
canvasHeight?: number;
}
export function DrawingTools({
onSaveRequest,
enableSnapping = true,
snapDistance = 10,
canvasWidth = 800,
canvasHeight = 600
}: DrawingToolsProps) {
// 状态管理
const [drawMode, setDrawMode] = useState<DrawMode>('none');
const [currentPoints, setCurrentPoints] = useState<DrawPoint[]>([]);
const [isDrawing, setIsDrawing] = useState(false);
const [mousePosition, setMousePosition] = useState<DrawPoint | null>(null);
const [snappedPoint, setSnappedPoint] = useState<DrawPoint | null>(null);
const [zoomLevel, setZoomLevel] = useState(1);
const canvasRef = useRef<HTMLDivElement>(null);
// 键盘事件处理
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+Z 撤销
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
handleUndo();
}
// Delete 清除
else if (e.key === 'Delete') {
e.preventDefault();
handleClear();
}
// Esc 取消绘制
else if (e.key === 'Escape' && isDrawing) {
e.preventDefault();
handleClear();
}
// Ctrl+S 保存到地块档案
else if (e.ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isDrawing, currentPoints, drawMode]);
// 撤销 - 删除最后一个点
const handleUndo = useCallback(() => {
if (currentPoints.length > 0) {
const newPoints = currentPoints.slice(0, -1);
setCurrentPoints(newPoints);
if (newPoints.length === 0) {
setIsDrawing(false);
}
toast.info(`已撤销,剩余 ${newPoints.length} 个点`);
} else {
toast.info('没有可撤销的操作');
}
}, [currentPoints]);
// 检查点是否接近(用于吸附)
const isNearPoint = (p1: DrawPoint, p2: DrawPoint): boolean => {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
return Math.sqrt(dx * dx + dy * dy) < snapDistance;
};
// 查找吸附点
const findSnapPoint = (point: DrawPoint): DrawPoint | null => {
if (!enableSnapping) return null;
for (const existing of currentPoints) {
if (isNearPoint(point, existing)) {
return existing;
}
}
return null;
};
// 检查多边形自相交
const checkSelfIntersection = (points: DrawPoint[]): boolean => {
if (points.length < 4) return false;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
for (let j = i + 2; j < points.length; j++) {
if (j === points.length - 1 && i === 0) continue; // 跳过首尾相连的边
const p3 = points[j];
const p4 = points[(j + 1) % points.length];
if (segmentsIntersect(p1, p2, p3, p4)) {
return true;
}
}
}
return false;
};
// 检查两条线段是否相交
const segmentsIntersect = (p1: DrawPoint, p2: DrawPoint, p3: DrawPoint, p4: DrawPoint): boolean => {
const ccw = (A: DrawPoint, B: DrawPoint, C: DrawPoint) => {
return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
};
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
};
// 计算多边形面积Shoelace公式
const calculateArea = (points: DrawPoint[]): number => {
if (points.length < 3) return 0;
let area = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
area += points[i].x * points[j].y;
area -= points[j].x * points[i].y;
}
// 转换为亩假设1单位 = 0.5米1亩 = 666.67平方米)
const squareMeters = Math.abs(area) * 0.25; // (0.5m * 0.5m)
const mu = squareMeters / 666.67;
return mu;
};
// 计算周长
const calculatePerimeter = (points: DrawPoint[]): number => {
if (points.length < 2) return 0;
let perimeter = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
const dx = points[j].x - points[i].x;
const dy = points[j].y - points[i].y;
perimeter += Math.sqrt(dx * dx + dy * dy);
}
// 转换为米假设1单位 = 0.5米)
return perimeter * 0.5;
};
// 鼠标移动
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / zoomLevel;
const y = (e.clientY - rect.top) / zoomLevel;
const point: DrawPoint = { x, y };
setMousePosition(point);
// 检查吸附
const snapPoint = findSnapPoint(point);
setSnappedPoint(snapPoint);
};
// 鼠标点击
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (drawMode === 'none') return;
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / zoomLevel;
const y = (e.clientY - rect.top) / zoomLevel;
let point: DrawPoint = { x, y };
// 吸附到已有点
if (snappedPoint) {
point = snappedPoint;
}
const newPoints = [...currentPoints, point];
// 多边形:点击起点闭合
if (drawMode === 'polygon') {
// 检查是否点击起点(闭合多边形)
if (currentPoints.length >= 3 && snappedPoint && snappedPoint === currentPoints[0]) {
// 不添加重复点,直接使用当前点
return;
}
setCurrentPoints(newPoints);
setIsDrawing(true);
} else if (drawMode === 'rectangle') {
if (currentPoints.length === 0) {
setCurrentPoints([point]);
setIsDrawing(true);
} else if (currentPoints.length === 1) {
// 创建矩形的四个点
const rectanglePoints = [
currentPoints[0],
{ x: point.x, y: currentPoints[0].y },
point,
{ x: currentPoints[0].x, y: point.y }
];
setCurrentPoints(rectanglePoints);
setIsDrawing(false);
}
} else if (drawMode === 'line') {
setCurrentPoints(newPoints);
if (newPoints.length >= 2) {
setIsDrawing(true);
}
} else if (drawMode === 'point') {
setCurrentPoints([point]);
setIsDrawing(false);
}
};
// 保存到地块档案
const handleSave = () => {
// 检查点数
if (drawMode === 'polygon' && currentPoints.length < 3) {
toast.error('多边形至少需要3个点');
return;
}
if (drawMode === 'line' && currentPoints.length < 2) {
toast.error('线至少需要2个点');
return;
}
if (currentPoints.length === 0) {
toast.error('请先绘制图形');
return;
}
// 计算几何属性
const area = drawMode === 'polygon' || drawMode === 'rectangle' ? calculateArea(currentPoints) : 0;
const perimeter = calculatePerimeter(currentPoints);
const selfIntersects = drawMode === 'polygon' && checkSelfIntersection(currentPoints);
if (selfIntersects) {
toast.error('多边形自相交,无法保存');
return;
}
const geometry: DrawGeometry = {
type: drawMode,
points: currentPoints,
area,
perimeter,
valid: !selfIntersects
};
// 触发保存对话框
if (onSaveRequest) {
onSaveRequest(geometry);
// 清除当前绘制
setCurrentPoints([]);
setIsDrawing(false);
setDrawMode('none');
}
};
// 清除绘制
const handleClear = () => {
if (currentPoints.length === 0) {
toast.info('当前没有绘制内容');
return;
}
if (confirm('确定要清除当前绘制吗?')) {
setCurrentPoints([]);
setIsDrawing(false);
setDrawMode('none');
toast.success('已清除绘制');
}
};
// 切换绘制模式
const switchDrawMode = (mode: DrawMode) => {
if (currentPoints.length > 0) {
if (!confirm('切换模式将清除当前绘制,是否继续?')) {
return;
}
}
setDrawMode(mode);
setCurrentPoints([]);
setIsDrawing(mode !== 'none');
const modeNames: Record<DrawMode, string> = {
none: '查看',
point: '点',
line: '线',
polygon: '多边形',
rectangle: '矩形',
circle: '圆形'
};
if (mode !== 'none') {
toast.success(`已切换到${modeNames[mode]}绘制模式`);
}
};
// 检查几何有效性
const isGeometryValid = (): boolean => {
if (drawMode === 'polygon' && currentPoints.length >= 3) {
return !checkSelfIntersection(currentPoints);
}
return true;
};
return (
<div className="grid grid-cols-4 gap-6">
{/* 左侧工具面板 */}
<div className="space-y-4">
{/* 绘制工具 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2">
<Button
variant={drawMode === 'none' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchDrawMode('none')}
>
<MousePointer className="w-4 h-4 mr-2" />
</Button>
<Button
variant={drawMode === 'point' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchDrawMode('point')}
>
<MapPin className="w-4 h-4 mr-2" />
</Button>
<Button
variant={drawMode === 'line' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchDrawMode('line')}
>
<Minus className="w-4 h-4 mr-2" />
线
</Button>
<Button
variant={drawMode === 'polygon' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchDrawMode('polygon')}
>
<Pentagon className="w-4 h-4 mr-2" />
</Button>
<Button
variant={drawMode === 'rectangle' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchDrawMode('rectangle')}
>
<Square className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
{/* 操作工具 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2">
<Button
variant="outline"
className="w-full justify-start"
onClick={handleUndo}
disabled={currentPoints.length === 0}
>
<Undo className="w-4 h-4 mr-2" />
</Button>
<Button
variant="default"
className="w-full justify-start bg-green-600 hover:bg-green-700 text-white"
onClick={handleSave}
disabled={currentPoints.length === 0}
>
<Save className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={handleClear}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
{/* 几何信息 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-3 text-sm">
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1 text-green-600 dark:text-green-400">{currentPoints.length} </div>
</div>
{(drawMode === 'polygon' || drawMode === 'rectangle') && currentPoints.length >= 3 && (
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1 text-green-600 dark:text-green-400">
{calculateArea(currentPoints).toFixed(2)}
</div>
</div>
)}
{currentPoints.length >= 2 && (
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1 text-green-600 dark:text-green-400">
{calculatePerimeter(currentPoints).toFixed(0)}
</div>
</div>
)}
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1 flex items-center gap-2">
{isGeometryValid() ? (
<>
<CheckCircle2 className="w-4 h-4 text-green-600" />
<span className="text-green-600"></span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="text-red-600"></span>
</>
)}
</div>
</div>
</div>
</Card>
{/* 吸附设置 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<Badge variant={enableSnapping ? 'default' : 'secondary'}>
{enableSnapping ? '开启' : '关闭'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span>{snapDistance}px</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span>{(zoomLevel * 100).toFixed(0)}%</span>
</div>
</div>
</Card>
</div>
{/* 画布区域 */}
<div className="col-span-3">
<Card className="p-0 overflow-hidden bg-card" style={{ height: `${canvasHeight}px` }}>
<div
ref={canvasRef}
className="relative w-full h-full bg-muted cursor-crosshair"
onMouseMove={handleMouseMove}
onClick={handleClick}
style={{ transform: `scale(${zoomLevel})`, transformOrigin: 'top left' }}
>
{/* 网格背景 */}
<div className="absolute inset-0" style={{
backgroundImage: `
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
`,
backgroundSize: '20px 20px'
}} />
{/* 绘制的点 */}
{currentPoints.map((point, index) => (
<div
key={index}
className="absolute w-3 h-3 bg-green-600 rounded-full border-2 border-background shadow-lg"
style={{
left: `${point.x - 6}px`,
top: `${point.y - 6}px`,
zIndex: 10
}}
/>
))}
{/* 绘制的线 */}
{currentPoints.length >= 2 && (
<svg className="absolute inset-0 pointer-events-none" style={{ zIndex: 5 }}>
{currentPoints.map((point, index) => {
if (index === 0) return null;
const prev = currentPoints[index - 1];
return (
<line
key={index}
x1={prev.x}
y1={prev.y}
x2={point.x}
y2={point.y}
stroke="#22c55e"
strokeWidth="2"
/>
);
})}
{/* 闭合多边形 */}
{drawMode === 'polygon' && currentPoints.length >= 3 && (
<line
x1={currentPoints[currentPoints.length - 1].x}
y1={currentPoints[currentPoints.length - 1].y}
x2={currentPoints[0].x}
y2={currentPoints[0].y}
stroke="#22c55e"
strokeWidth="2"
strokeDasharray="5,5"
/>
)}
</svg>
)}
{/* 多边形填充 */}
{(drawMode === 'polygon' || drawMode === 'rectangle') && currentPoints.length >= 3 && (
<svg className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
<polygon
points={currentPoints.map(p => `${p.x},${p.y}`).join(' ')}
fill="#22c55e"
fillOpacity="0.2"
stroke="none"
/>
</svg>
)}
{/* 吸附点提示 */}
{snappedPoint && (
<div
className="absolute w-5 h-5 border-2 border-blue-500 rounded-full"
style={{
left: `${snappedPoint.x - 10}px`,
top: `${snappedPoint.y - 10}px`,
zIndex: 15
}}
/>
)}
{/* 当前模式提示 */}
{isDrawing && (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 pointer-events-none">
<Card className="px-4 py-2 bg-green-600 text-white border-0">
<div className="text-sm">
{drawMode === 'polygon' && `已标记 ${currentPoints.length} 个点,点击起点闭合或点击"保存"按钮`}
{drawMode === 'rectangle' && (currentPoints.length === 0 ? '点击第一个角点' : '点击对角点完成')}
{drawMode === 'line' && `已标记 ${currentPoints.length} 个点,点击"保存"按钮结束`}
</div>
</Card>
</div>
)}
{/* 操作提示 */}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 pointer-events-none">
<Card className="px-4 py-2 bg-background/90 border text-xs text-muted-foreground">
Ctrl+Z:撤销 | Ctrl+S:保存 | Delete:清除 | Esc:取消
</Card>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,934 @@
/**
* GIS编辑工具组件
* 提供节点编辑、地块分割、地块合并功能
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
MousePointer,
Move,
Scissors,
Copy,
Plus,
Minus as MinusIcon,
Trash2,
Save,
AlertCircle,
CheckCircle2,
RotateCcw
} from 'lucide-react';
import { toast } from 'sonner';
import { DrawField, DrawGeometry } from './drawEditReducer';
export type EditMode = 'none' | 'node' | 'split' | 'merge';
interface EditingToolsProps {
fields: DrawField[];
onFieldsUpdate?: (fields: DrawField[]) => void;
onGeometryChange?: (fieldId: string, geometry: DrawGeometry) => void;
onSaveRequest?: (geometry: DrawGeometry) => void;
canvasWidth?: number;
canvasHeight?: number;
}
export function EditingTools({
fields: initialFields = [],
onFieldsUpdate,
onGeometryChange,
onSaveRequest,
canvasWidth = 800,
canvasHeight = 600
}: EditingToolsProps) {
const [editMode, setEditMode] = useState<EditMode>('none');
const [fields, setFields] = useState<DrawField[]>(initialFields);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [splitLine, setSplitLine] = useState<any[]>([]);
const [selectedForMerge, setSelectedForMerge] = useState<string[]>([]);
// 初始化历史记录,包含初始状态
const [history, setHistory] = useState<DrawField[][]>([JSON.parse(JSON.stringify(initialFields))]);
const [historyIndex, setHistoryIndex] = useState(0);
const canvasRef = useRef<HTMLDivElement>(null);
// 键盘事件处理
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+Z 撤销
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
handleUndo();
}
// Ctrl+Y 或 Ctrl+Shift+Z 重做
else if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
handleRedo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [historyIndex, history]);
// 添加到历史记录
const addToHistory = useCallback((newFields: DrawField[]) => {
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newFields)));
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}, [history, historyIndex]);
// 撤销
const handleUndo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
const previousFields = history[newIndex];
setFields(previousFields);
if (onFieldsUpdate) onFieldsUpdate(previousFields);
toast.success(`已撤销,剩余 ${history.length - newIndex - 1} 步可重做`);
} else {
toast.info('没有可撤销的操作');
}
};
// 重做
const handleRedo = () => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
const nextFields = history[newIndex];
setFields(nextFields);
if (onFieldsUpdate) onFieldsUpdate(nextFields);
toast.success(`已重做,剩余 ${history.length - newIndex - 1} 步可重做`);
} else {
toast.info('没有可重做的操作');
}
};
// 计算面积
const calculateArea = (points: any[]): number => {
if (points.length < 3) return 0;
let area = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
area += points[i].x * points[j].y;
area -= points[j].x * points[i].y;
}
const squareMeters = Math.abs(area) * 0.25;
return squareMeters / 666.67; // 转换为亩
};
// 计算周长
const calculatePerimeter = (points: any[]): number => {
if (points.length < 2) return 0;
let perimeter = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
const dx = points[j].x - points[i].x;
const dy = points[j].y - points[i].y;
perimeter += Math.sqrt(dx * dx + dy * dy);
}
return perimeter * 0.5; // 转换为米
};
// 检查点是否在多边形内
const isPointInPolygon = (point: any, polygon: any[]): boolean => {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x, yi = polygon[i].y;
const xj = polygon[j].x, yj = polygon[j].y;
const intersect = ((yi > point.y) !== (yj > point.y))
&& (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
};
// 查找点击的地块
const findFieldAtPoint = (point: any): DrawField | null => {
for (const field of fields) {
if (isPointInPolygon(point, field.points)) {
return field;
}
}
return null;
};
// 查找最近的节点
const findNearestNode = (point: any, fieldId: string): number | null => {
const field = fields.find(f => f.id === fieldId);
if (!field) return null;
let minDist = Infinity;
let nearestIndex: number | null = null;
field.points.forEach((node, index) => {
const dist = Math.sqrt(
Math.pow(node.x - point.x, 2) + Math.pow(node.y - point.y, 2)
);
if (dist < minDist && dist < 15) { // 15px容差
minDist = dist;
nearestIndex = index;
}
});
return nearestIndex;
};
// 处理鼠标按下
const handleMouseDown = (e: React.MouseEvent) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const point = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
if (editMode === 'node') {
// 节点编辑模式
const field = selectedFieldId ? fields.find(f => f.id === selectedFieldId) : findFieldAtPoint(point);
if (field) {
setSelectedFieldId(field.id);
const nodeIndex = findNearestNode(point, field.id);
if (nodeIndex !== null) {
setSelectedNodeIndex(nodeIndex);
setIsDragging(true);
}
}
} else if (editMode === 'split') {
// 分割模式 - 绘制分割线
setSplitLine([...splitLine, point]);
} else if (editMode === 'merge') {
// 合并模式 - 选择地块
const field = findFieldAtPoint(point);
if (field) {
if (selectedForMerge.includes(field.id)) {
setSelectedForMerge(selectedForMerge.filter(id => id !== field.id));
} else {
setSelectedForMerge([...selectedForMerge, field.id]);
}
}
}
};
// 处理鼠标移动
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !selectedFieldId || selectedNodeIndex === null) return;
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const point = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
const newFields = fields.map(field => {
if (field.id === selectedFieldId) {
const newPoints = [...field.points];
newPoints[selectedNodeIndex] = point;
return { ...field, points: newPoints };
}
return field;
});
setFields(newFields);
};
// 处理鼠标释放
const handleMouseUp = () => {
if (isDragging && selectedFieldId) {
addToHistory(fields);
const field = fields.find(f => f.id === selectedFieldId);
if (field && onGeometryChange) {
onGeometryChange(field.id, {
type: 'polygon',
points: field.points,
area: calculateArea(field.points),
perimeter: calculatePerimeter(field.points),
valid: true
});
}
toast.success('节点位置已更新');
}
setIsDragging(false);
setSelectedNodeIndex(null);
};
// 添加节点
const handleAddNode = () => {
if (!selectedFieldId || selectedNodeIndex === null) {
toast.error('请先选择要添加节点的边');
return;
}
const field = fields.find(f => f.id === selectedFieldId);
if (!field) return;
const nextIndex = (selectedNodeIndex + 1) % field.points.length;
const p1 = field.points[selectedNodeIndex];
const p2 = field.points[nextIndex];
// 在中点添加新节点
const newPoint = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
const newPoints = [
...field.points.slice(0, nextIndex),
newPoint,
...field.points.slice(nextIndex)
];
const newFields = fields.map(f =>
f.id === selectedFieldId ? { ...f, points: newPoints } : f
);
setFields(newFields);
addToHistory(newFields);
if (onFieldsUpdate) onFieldsUpdate(newFields);
toast.success('已添加节点');
};
// 删除节点
const handleDeleteNode = () => {
if (!selectedFieldId || selectedNodeIndex === null) {
toast.error('请先选择要删除的节点');
return;
}
const field = fields.find(f => f.id === selectedFieldId);
if (!field || field.points.length <= 3) {
toast.error('多边形至少需要3个顶点');
return;
}
const newPoints = field.points.filter((_, index) => index !== selectedNodeIndex);
const newFields = fields.map(f =>
f.id === selectedFieldId ? { ...f, points: newPoints } : f
);
setFields(newFields);
addToHistory(newFields);
setSelectedNodeIndex(null);
if (onFieldsUpdate) onFieldsUpdate(newFields);
toast.success('已删除节点');
};
// 执行分割
const handleExecuteSplit = () => {
if (splitLine.length < 2) {
toast.error('分割线至少需要2个点');
return;
}
if (!selectedFieldId) {
toast.error('请先选择要分割的地块');
return;
}
const sourceField = fields.find(f => f.id === selectedFieldId);
if (!sourceField) {
toast.error('未找到选中的地块');
return;
}
// 简化的分割实现:将地块分成两部分
// 使用分割线的中点作为分割参考
const midPoint = {
x: (splitLine[0].x + splitLine[splitLine.length - 1].x) / 2,
y: (splitLine[0].y + splitLine[splitLine.length - 1].y) / 2
};
// 计算地块中心
const centerX = sourceField.points.reduce((sum, p) => sum + p.x, 0) / sourceField.points.length;
const centerY = sourceField.points.reduce((sum, p) => sum + p.y, 0) / sourceField.points.length;
// 根据分割线方向分离点
const isVerticalSplit = Math.abs(splitLine[splitLine.length - 1].y - splitLine[0].y) >
Math.abs(splitLine[splitLine.length - 1].x - splitLine[0].x);
const part1Points: any[] = [];
const part2Points: any[] = [];
sourceField.points.forEach(point => {
if (isVerticalSplit) {
if (point.x < midPoint.x) {
part1Points.push(point);
} else {
part2Points.push(point);
}
} else {
if (point.y < midPoint.y) {
part1Points.push(point);
} else {
part2Points.push(point);
}
}
});
// 确保两部分都有足够的点
if (part1Points.length < 3 || part2Points.length < 3) {
toast.error('分割失败:分割后的地块顶点数不足');
setSplitLine([]);
return;
}
// 创建两个新地块
const field1: DrawField = {
id: `split-${Date.now()}-1`,
name: `${sourceField.name}-1`,
points: part1Points,
color: sourceField.color || '#22c55e',
area: calculateArea(part1Points),
perimeter: calculatePerimeter(part1Points)
};
const field2: DrawField = {
id: `split-${Date.now()}-2`,
name: `${sourceField.name}-2`,
points: part2Points,
color: sourceField.color === '#22c55e' ? '#3b82f6' : '#22c55e',
area: calculateArea(part2Points),
perimeter: calculatePerimeter(part2Points)
};
// 更新地块列表
const newFields = [
...fields.filter(f => f.id !== selectedFieldId),
field1,
field2
];
setFields(newFields);
addToHistory(newFields);
setSplitLine([]);
setSelectedFieldId(null);
if (onFieldsUpdate) onFieldsUpdate(newFields);
toast.success('地块分割成功生成了2个新地块');
};
// 执行合并
const handleExecuteMerge = () => {
if (selectedForMerge.length < 2) {
toast.error('请至少选择2个地块进行合并');
return;
}
// 简化实现:这里应该实现多边形合并算法
const mergedFields = fields.filter(f => selectedForMerge.includes(f.id));
const allPoints: any[] = [];
mergedFields.forEach(f => allPoints.push(...f.points));
// 创建凸包作为合并结果(简化算法)
const convexHull = computeConvexHull(allPoints);
const mergedField: DrawField = {
id: `merged-${Date.now()}`,
name: `合并地块 (${mergedFields.map(f => f.name).join(' + ')})`,
points: convexHull,
color: mergedFields[0].color || '#22c55e',
area: calculateArea(convexHull),
perimeter: calculatePerimeter(convexHull)
};
const newFields = [
...fields.filter(f => !selectedForMerge.includes(f.id)),
mergedField
];
setFields(newFields);
addToHistory(newFields);
setSelectedForMerge([]);
if (onFieldsUpdate) onFieldsUpdate(newFields);
toast.success(`已合并 ${mergedFields.length} 个地块`);
};
// 计算凸包Graham扫描算法
const computeConvexHull = (points: any[]): any[] => {
if (points.length < 3) return points;
// 找到最下最左的点
let start = points[0];
points.forEach(p => {
if (p.y < start.y || (p.y === start.y && p.x < start.x)) {
start = p;
}
});
// 按极角排序
const sorted = points.filter(p => p !== start).sort((a, b) => {
const angleA = Math.atan2(a.y - start.y, a.x - start.x);
const angleB = Math.atan2(b.y - start.y, b.x - start.x);
return angleA - angleB;
});
const hull: any[] = [start];
for (const point of sorted) {
while (hull.length >= 2) {
const p2 = hull[hull.length - 1];
const p1 = hull[hull.length - 2];
const cross = (p2.x - p1.x) * (point.y - p1.y) - (p2.y - p1.y) * (point.x - p1.x);
if (cross <= 0) {
hull.pop();
} else {
break;
}
}
hull.push(point);
}
return hull;
};
// 切换编辑模式
const switchEditMode = (mode: EditMode) => {
setEditMode(mode);
setSelectedFieldId(null);
setSelectedNodeIndex(null);
setSplitLine([]);
setSelectedForMerge([]);
const modeNames: Record<EditMode, string> = {
none: '查看',
node: '节点编辑',
split: '地块分割',
merge: '地块合并'
};
if (mode !== 'none') {
toast.success(`已切换到${modeNames[mode]}模式`);
}
};
const selectedField = selectedFieldId ? fields.find(f => f.id === selectedFieldId) : null;
return (
<div className="grid grid-cols-4 gap-6">
{/* 左侧工具面板 */}
<div className="space-y-4">
{/* 编辑模式 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2">
<Button
variant={editMode === 'none' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchEditMode('none')}
>
<MousePointer className="w-4 h-4 mr-2" />
</Button>
<Button
variant={editMode === 'node' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchEditMode('node')}
>
<Move className="w-4 h-4 mr-2" />
</Button>
<Button
variant={editMode === 'split' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchEditMode('split')}
>
<Scissors className="w-4 h-4 mr-2" />
</Button>
<Button
variant={editMode === 'merge' ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => switchEditMode('merge')}
>
<Copy className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
{/* 节点操作 */}
{editMode === 'node' && (
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2">
<Button
variant="outline"
className="w-full justify-start"
onClick={handleAddNode}
disabled={!selectedFieldId || selectedNodeIndex === null}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="w-full justify-start text-destructive"
onClick={handleDeleteNode}
disabled={!selectedFieldId || selectedNodeIndex === null}
>
<MinusIcon className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
)}
{/* 分割操作 */}
{editMode === 'split' && (
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-3">
<div className="p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-xs text-blue-800 dark:text-blue-200">
<p className="mb-2"></p>
<ol className="list-decimal list-inside space-y-1">
<li></li>
<li>线2</li>
<li>"执行分割"</li>
</ol>
</div>
{selectedFieldId && (
<div className="p-2 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md text-xs text-green-700 dark:text-green-200">
</div>
)}
<Button
variant="outline"
className="w-full justify-start"
onClick={handleExecuteSplit}
disabled={splitLine.length < 2 || !selectedFieldId}
>
<Scissors className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="w-full justify-start text-destructive"
onClick={() => setSplitLine([])}
disabled={splitLine.length === 0}
>
<Trash2 className="w-4 h-4 mr-2" />
线
</Button>
</div>
<div className="mt-3 space-y-1">
<div className="text-sm text-muted-foreground">
: {splitLine.length}
</div>
{!selectedFieldId && (
<div className="text-xs text-amber-600">
</div>
)}
</div>
</Card>
)}
{/* 合并操作 */}
{editMode === 'merge' && (
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-3">
<div className="p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-xs text-blue-800 dark:text-blue-200">
<p className="mb-2"></p>
<ol className="list-decimal list-inside space-y-1">
<li>2</li>
<li></li>
<li>"执行合并"</li>
</ol>
</div>
{selectedForMerge.length > 0 && (
<div className="p-2 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md text-xs text-green-700 dark:text-green-200">
{selectedForMerge.length}
{selectedForMerge.length >= 2 && ' - 可以合并'}
</div>
)}
<Button
variant="outline"
className="w-full justify-start"
onClick={handleExecuteMerge}
disabled={selectedForMerge.length < 2}
>
<Copy className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="w-full justify-start text-destructive"
onClick={() => setSelectedForMerge([])}
disabled={selectedForMerge.length === 0}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="mt-3 space-y-1">
<div className="text-sm text-muted-foreground">
: {selectedForMerge.length}
</div>
{selectedForMerge.length === 1 && (
<div className="text-xs text-amber-600">
2
</div>
)}
</div>
</Card>
)}
{/* 历史操作 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2">
<Button
variant="outline"
className="w-full justify-start"
onClick={handleUndo}
disabled={historyIndex <= 0}
>
<RotateCcw className="w-4 h-4 mr-2" />
{historyIndex > 0 && `(${historyIndex})`}
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={handleRedo}
disabled={historyIndex >= history.length - 1}
>
<RotateCcw className="w-4 h-4 mr-2 scale-x-[-1]" />
{historyIndex < history.length - 1 && `(${history.length - historyIndex - 1})`}
</Button>
</div>
<div className="mt-3 text-xs text-muted-foreground">
{history.length} | {historyIndex + 1}
</div>
</Card>
{/* 保存操作 */}
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2">
{/* 批量保存分割/合并后的地块 */}
{fields.length > 0 && (
<Button
variant="default"
className="w-full justify-start bg-green-600 hover:bg-green-700 text-white"
onClick={() => {
// 保存所有新创建的地块ID包含split或merged的
const newFields = fields.filter(f =>
f.id.includes('split-') || f.id.includes('merged-')
);
if (newFields.length === 0) {
toast.info('没有需要保存的新地块');
return;
}
// 保存第一个新地块
const geometry: DrawGeometry = {
type: 'polygon',
points: newFields[0].points,
area: calculateArea(newFields[0].points),
perimeter: calculatePerimeter(newFields[0].points),
valid: true
};
if (onSaveRequest) {
onSaveRequest(geometry);
toast.success(`发现 ${newFields.length} 个新地块,正在保存第一个...`);
}
}}
disabled={!onSaveRequest}
>
<Save className="w-4 h-4 mr-2" />
</Button>
)}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{fields.filter(f => f.id.includes('split-') || f.id.includes('merged-')).length > 0
? `${fields.filter(f => f.id.includes('split-') || f.id.includes('merged-')).length} 个新地块可保存`
: '请先进行分割或合并操作以创建新地块'}
</div>
</Card>
{/* 地块信息 */}
{selectedField && (
<Card className="p-4 bg-card">
<h4 className="mb-3"></h4>
<div className="space-y-2 text-sm">
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1">{selectedField.name}</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1">{selectedField.points.length} </div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1 text-green-600 dark:text-green-400">
{calculateArea(selectedField.points).toFixed(2)}
</div>
</div>
<div>
<div className="text-muted-foreground"></div>
<div className="mt-1 text-green-600 dark:text-green-400">
{calculatePerimeter(selectedField.points).toFixed(0)}
</div>
</div>
</div>
</Card>
)}
</div>
{/* 编辑画布 */}
<div className="col-span-3">
<Card className="overflow-hidden bg-card">
<div
ref={canvasRef}
className="relative bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950"
style={{ height: '700px', cursor: isDragging ? 'grabbing' : 'default' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* 网格背景 */}
<div
className="absolute inset-0"
style={{
backgroundImage: `
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px)
`,
backgroundSize: '50px 50px',
width: canvasWidth,
height: canvasHeight
}}
/>
{/* SVG图层 */}
<svg
className="absolute inset-0"
style={{ width: canvasWidth, height: canvasHeight }}
>
{/* 绘制所有地块 */}
{fields.map(field => {
const isSelected = field.id === selectedFieldId;
const isSelectedForMerge = selectedForMerge.includes(field.id);
return (
<g key={field.id}>
<polygon
points={field.points.map(p => `${p.x},${p.y}`).join(' ')}
fill={field.color ? field.color + '40' : '#22c55e40'}
stroke={isSelected || isSelectedForMerge ? '#3b82f6' : field.color || '#22c55e'}
strokeWidth={isSelected || isSelectedForMerge ? '3' : '2'}
opacity={isSelectedForMerge ? '0.8' : '1'}
/>
{/* 地块标签 */}
<text
x={field.points.reduce((sum, p) => sum + p.x, 0) / field.points.length}
y={field.points.reduce((sum, p) => sum + p.y, 0) / field.points.length}
textAnchor="middle"
fill={field.color || '#22c55e'}
className="text-sm pointer-events-none"
>
{field.name}
</text>
</g>
);
})}
{/* 分割线 */}
{splitLine.length > 0 && (
<polyline
points={splitLine.map(p => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke="#ef4444"
strokeWidth="2"
strokeDasharray="5,5"
/>
)}
</svg>
{/* 节点编辑模式 - 显示所有节点 */}
{editMode === 'node' && selectedField && selectedField.points.map((point, index) => {
const isSelected = index === selectedNodeIndex;
return (
<div
key={index}
className={`absolute w-3 h-3 rounded-full border-2 border-white shadow-lg transform -translate-x-1/2 -translate-y-1/2 cursor-move ${
isSelected ? 'bg-blue-600 w-4 h-4' : 'bg-green-600'
}`}
style={{ left: point.x, top: point.y }}
>
{isSelected && (
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-1 text-xs bg-white px-1 rounded shadow whitespace-nowrap">
{index + 1}
</div>
)}
</div>
);
})}
{/* 分割模式 - 显示分割点 */}
{editMode === 'split' && splitLine.map((point, index) => (
<div
key={index}
className="absolute w-3 h-3 bg-red-600 rounded-full border-2 border-white shadow-lg transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{ left: point.x, top: point.y }}
/>
))}
{/* 状态栏 */}
<div className="absolute bottom-4 left-4 pointer-events-none">
<Card className="px-3 py-2 bg-background/90 border">
<div className="flex items-center gap-2 text-sm">
<Badge className="bg-blue-600">
{editMode === 'none' && '查看模式'}
{editMode === 'node' && '节点编辑'}
{editMode === 'split' && '地块分割'}
{editMode === 'merge' && '地块合并'}
</Badge>
</div>
</Card>
</div>
</div>
</Card>
{/* 操作说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 mt-4">
<h4 className="text-blue-900 dark:text-blue-100 mb-2"> </h4>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm text-blue-800 dark:text-blue-200">
<div> <strong></strong></div>
<div> <strong></strong></div>
<div> <strong></strong></div>
<div> <strong></strong>线穿</div>
<div> <strong></strong></div>
<div> <strong></strong></div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,397 @@
/**
* 地块信息录入对话框
* 用于从绘制工具或编辑工具保存地块到地块档案
*/
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { DrawGeometry } from './drawEditReducer';
import { Save, X, Tag, MapPin } from 'lucide-react';
import { toast } from 'sonner';
interface FieldEntryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
geometry: DrawGeometry;
onSaved?: (field: any) => void;
}
export function FieldEntryDialog({
open,
onOpenChange,
geometry,
onSaved
}: FieldEntryDialogProps) {
const [formData, setFormData] = useState({
code: '',
name: '',
area: 0,
perimeter: 0,
location: '',
owner: '',
soilType: 'loamy',
landUseType: 'farmland',
plantingMode: 'open-field',
irrigationType: 'drip',
tags: [],
status: 'active',
description: '',
});
const [tagInput, setTagInput] = useState('');
// 初始化表单数据
useEffect(() => {
if (geometry && open) {
// 自动生成地块编号和名称
const fields = JSON.parse(localStorage.getItem('smart_agriculture_fields') || '[]');
const count = fields.length + 1;
// 将绘制的点转换为地理坐标(这里使用模拟数据)
const coordinates = geometry.points.map((p, index) => ({
lat: 39.9042 + (p.y - 300) / 10000,
lng: 116.4074 + (p.x - 400) / 10000
}));
// 计算中心点
const centerLat = coordinates.reduce((sum, p) => sum + p.lat, 0) / coordinates.length;
const centerLng = coordinates.reduce((sum, p) => sum + p.lng, 0) / coordinates.length;
setFormData({
code: `FIELD-${String(count).padStart(3, '0')}`,
name: `地块${String.fromCharCode(64 + count)}`,
area: geometry.area || 0,
perimeter: geometry.perimeter || 0,
location: `北京市XX区`,
owner: '',
soilType: 'loamy',
landUseType: 'farmland',
plantingMode: 'open-field',
irrigationType: 'drip',
tags: [],
status: 'active',
description: `通过GIS工具绘制的${geometry.type === 'polygon' ? '多边形' : geometry.type === 'rectangle' ? '矩形' : ''}地块`,
});
}
}, [geometry, open]);
const handleSave = () => {
// 验证必填项
if (!formData.code?.trim()) {
toast.error('请输入地块编号');
return;
}
if (!formData.name?.trim()) {
toast.error('请输入地块名称');
return;
}
if (geometry.points.length < 3) {
toast.error('地块边界数据无效');
return;
}
try {
// 保存到localStorage地块档案
const fields = JSON.parse(localStorage.getItem('smart_agriculture_fields') || '[]');
const newField = {
id: `field-${Date.now()}`,
code: formData.code!,
name: formData.name!,
area: formData.area!,
perimeter: formData.perimeter || 0,
location: formData.location || '',
owner: formData.owner || '',
soilType: formData.soilType!,
landUseType: formData.landUseType!,
plantingMode: formData.plantingMode!,
irrigationType: formData.irrigationType || 'drip',
tags: formData.tags || [],
photos: [],
documents: [],
status: formData.status || 'active',
description: formData.description || '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'admin',
currentVersion: 1,
geometry: geometry,
};
fields.push(newField);
localStorage.setItem('smart_agriculture_fields', JSON.stringify(fields));
toast.success(`地块 ${newField.name} 已保存到地块档案`);
// 调用回调
if (onSaved) {
onSaved(newField);
}
// 关闭对话框
onOpenChange(false);
} catch (error) {
console.error('保存地块失败:', error);
toast.error('保存失败,请重试');
}
};
const handleAddTag = () => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
const handleRemoveTag = (tag: string) => {
setFormData({
...formData,
tags: formData.tags.filter(t => t !== tag),
});
};
// 土壤类型选项
const soilTypes = [
{ value: 'sandy', label: '沙土' },
{ value: 'loamy', label: '壤土' },
{ value: 'clay', label: '粘土' },
{ value: 'silt', label: '淤泥土' },
{ value: 'peat', label: '泥炭土' },
{ value: 'saline', label: '盐碱土' },
];
// 种植模式选项
const plantingModes = [
{ value: 'open-field', label: '露地' },
{ value: 'greenhouse', label: '大棚' },
{ value: 'orchard', label: '果园' },
{ value: 'paddy', label: '水田' },
{ value: 'dryland', label: '旱地' },
];
// 灌溉方式选项
const irrigationTypes = [
{ value: 'drip', label: '滴灌' },
{ value: 'sprinkler', label: '喷灌' },
{ value: 'flood', label: '漫灌' },
{ value: 'micro-sprinkler', label: '微喷' },
{ value: 'none', label: '无灌溉' },
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 几何信息汇总 */}
<div className="p-4 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<MapPin className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-800 dark:text-green-200"></span>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1">
{geometry.type === 'polygon' && '多边形'}
{geometry.type === 'rectangle' && '矩形'}
{geometry.type === 'line' && '线'}
{geometry.type === 'point' && '点'}
</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1">{geometry.points.length} </div>
</div>
{geometry.area && geometry.area > 0 && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 text-green-600 dark:text-green-400">{geometry.area.toFixed(2)} </div>
</div>
)}
{geometry.perimeter && (
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1 text-blue-600 dark:text-blue-400">{geometry.perimeter.toFixed(0)} </div>
</div>
)}
</div>
</div>
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="例如FIELD-001"
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如地块A"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
placeholder="例如北京市XX区"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
placeholder="例如:张三"
/>
</div>
</div>
{/* 地块属性 */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label></Label>
<Select
value={formData.soilType}
onValueChange={(value: any) => setFormData({ ...formData, soilType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{soilTypes.map(type => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.plantingMode}
onValueChange={(value: any) => setFormData({ ...formData, plantingMode: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{plantingModes.map(mode => (
<SelectItem key={mode.value} value={mode.value}>
{mode.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.irrigationType}
onValueChange={(value: any) => setFormData({ ...formData, irrigationType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{irrigationTypes.map(type => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 标签 */}
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
}}
placeholder="输入标签后按回车添加"
/>
<Button type="button" onClick={handleAddTag} variant="outline">
<Tag className="w-4 h-4" />
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{formData.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="ml-2 hover:text-destructive"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* 备注 */}
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="输入地块相关的备注信息"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} className="bg-green-600 hover:bg-green-700">
<Save className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Pen, Edit3, BookOpen, Sparkles } from 'lucide-react';
interface UsageGuideProps {
onSwitchToAdvanced?: () => void;
}
export function UsageGuide({ onSwitchToAdvanced }: UsageGuideProps) {
return (
<div className="grid grid-cols-2 gap-6">
{/* 绘制工具指南 */}
<Card className="p-6 bg-card">
<div className="flex items-center gap-2 mb-4">
<Pen className="w-5 h-5 text-green-600 dark:text-green-400" />
<h3 className="text-green-800 dark:text-green-400"></h3>
</div>
<div className="space-y-4 text-sm">
<div>
<h4 className="mb-2">1. </h4>
<ul className="space-y-1 text-muted-foreground ml-4">
<li> </li>
<li> 3</li>
<li> "完成"</li>
<li> "保存"</li>
</ul>
</div>
<div>
<h4 className="mb-2">2. </h4>
<ul className="space-y-1 text-muted-foreground ml-4">
<li> "保存"</li>
<li> "新增地块"</li>
<li> </li>
<li> </li>
</ul>
</div>
<div>
<h4 className="mb-2">3. </h4>
<ul className="space-y-1 text-muted-foreground ml-4">
<li> Ctrl+Z</li>
<li> Ctrl+Y</li>
<li> Enter</li>
<li> Ctrl+S</li>
</ul>
</div>
</div>
</Card>
{/* 编辑工具指南 */}
<Card className="p-6 bg-card">
<div className="flex items-center gap-2 mb-4">
<Edit3 className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h3 className="text-blue-800 dark:text-blue-400"></h3>
</div>
<div className="space-y-4 text-sm">
<div>
<h4 className="mb-2">1. </h4>
<ul className="space-y-1 text-muted-foreground ml-4">
<li> </li>
<li> </li>
<li> "保存"</li>
</ul>
</div>
<div>
<h4 className="mb-2">2. </h4>
<ul className="space-y-1 text-muted-foreground ml-4">
<li> </li>
<li> 线</li>
<li> "保存"</li>
</ul>
</div>
<div>
<h4 className="mb-2">3. </h4>
<ul className="space-y-1 text-muted-foreground ml-4">
<li> </li>
<li> "合并"</li>
<li> "保存"</li>
</ul>
</div>
</div>
</Card>
{/* 高级功能说明 */}
<Card className="p-6 col-span-2 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5 text-green-600 dark:text-green-400" />
<h3 className="text-green-800 dark:text-green-400"></h3>
</div>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm text-green-800 dark:text-green-200">
<div> <strong></strong>KMLGeoJSONShapefile格式</div>
<div> <strong></strong>/Leaflet地图引擎</div>
<div> <strong></strong>WGS84GCJ02坐标系转换</div>
<div> <strong></strong></div>
<div> <strong></strong></div>
<div> <strong></strong></div>
<div> <strong></strong></div>
<div> <strong></strong></div>
</div>
{onSwitchToAdvanced && (
<div className="mt-4 pt-4 border-t border-green-300 dark:border-green-700">
<Button
onClick={onSwitchToAdvanced}
className="bg-green-600 hover:bg-green-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</Card>
{/* 快捷键说明 */}
<Card className="p-6 col-span-2 bg-card">
<h3 className="mb-4"> </h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl + Z</kbd>
<span className="text-muted-foreground"></span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl + Y</kbd>
<span className="text-muted-foreground"></span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl + S</kbd>
<span className="text-muted-foreground"></span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Delete</kbd>
<span className="text-muted-foreground"></span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Esc</kbd>
<span className="text-muted-foreground"></span>
</div>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Enter</kbd>
<span className="text-muted-foreground"></span>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useReducer } from 'react';
// 地块数据接口
export interface DrawField {
id: string;
name: string;
points: { x: number; y: number }[];
color: string;
area?: number;
perimeter?: number;
}
// 绘制几何接口
export interface DrawGeometry {
type: 'point' | 'line' | 'polygon' | 'rectangle' | 'circle';
points: { x: number; y: number }[];
area?: number;
perimeter?: number;
valid?: boolean;
}
// 绘制编辑状态接口
export interface DrawEditState {
showAdvancedEditor: boolean;
activeTab: 'draw' | 'edit' | 'guide';
fields: DrawField[];
showSaveDialog: boolean;
pendingGeometry: DrawGeometry | null;
}
// Action类型
export type DrawEditAction =
| { type: 'SET_SHOW_ADVANCED_EDITOR'; payload: boolean }
| { type: 'SET_ACTIVE_TAB'; payload: 'draw' | 'edit' | 'guide' }
| { type: 'SET_FIELDS'; payload: DrawField[] }
| { type: 'UPDATE_FIELD'; payload: { id: string; updates: Partial<DrawField> } }
| { type: 'ADD_FIELD'; payload: DrawField }
| { type: 'DELETE_FIELD'; payload: string }
| { type: 'SET_SHOW_SAVE_DIALOG'; payload: boolean }
| { type: 'SET_PENDING_GEOMETRY'; payload: DrawGeometry | null };
// 计算面积的辅助函数
const calculateArea = (points: { x: number; y: number }[]): number => {
if (points.length < 3) return 0;
let area = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
area += points[i].x * points[j].y;
area -= points[j].x * points[i].y;
}
const squareMeters = Math.abs(area) * 0.25;
return squareMeters / 666.67; // 转换为亩
};
// 计算周长的辅助函数
const calculatePerimeter = (points: { x: number; y: number }[]): number => {
if (points.length < 2) return 0;
let perimeter = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
const dx = points[j].x - points[i].x;
const dy = points[j].y - points[i].y;
perimeter += Math.sqrt(dx * dx + dy * dy);
}
return perimeter * 0.5; // 转换为米
};
// 初始状态
const initialState: DrawEditState = {
showAdvancedEditor: false,
activeTab: 'draw',
fields: [
{
id: 'field-1',
name: '地块A',
points: [
{ x: 200, y: 150 },
{ x: 350, y: 150 },
{ x: 350, y: 280 },
{ x: 200, y: 280 }
],
color: '#22c55e',
area: calculateArea([
{ x: 200, y: 150 },
{ x: 350, y: 150 },
{ x: 350, y: 280 },
{ x: 200, y: 280 }
]),
perimeter: calculatePerimeter([
{ x: 200, y: 150 },
{ x: 350, y: 150 },
{ x: 350, y: 280 },
{ x: 200, y: 280 }
])
},
{
id: 'field-2',
name: '地块B',
points: [
{ x: 380, y: 150 },
{ x: 520, y: 150 },
{ x: 520, y: 280 },
{ x: 380, y: 280 }
],
color: '#3b82f6',
area: calculateArea([
{ x: 380, y: 150 },
{ x: 520, y: 150 },
{ x: 520, y: 280 },
{ x: 380, y: 280 }
]),
perimeter: calculatePerimeter([
{ x: 380, y: 150 },
{ x: 520, y: 150 },
{ x: 520, y: 280 },
{ x: 380, y: 280 }
])
},
{
id: 'field-3',
name: '地块C',
points: [
{ x: 200, y: 310 },
{ x: 350, y: 310 },
{ x: 350, y: 440 },
{ x: 200, y: 440 }
],
color: '#f97316',
area: calculateArea([
{ x: 200, y: 310 },
{ x: 350, y: 310 },
{ x: 350, y: 440 },
{ x: 200, y: 440 }
]),
perimeter: calculatePerimeter([
{ x: 200, y: 310 },
{ x: 350, y: 310 },
{ x: 350, y: 440 },
{ x: 200, y: 440 }
])
}
],
showSaveDialog: false,
pendingGeometry: null,
};
// Reducer函数
export function drawEditReducer(state: DrawEditState, action: DrawEditAction): DrawEditState {
switch (action.type) {
case 'SET_SHOW_ADVANCED_EDITOR':
return { ...state, showAdvancedEditor: action.payload };
case 'SET_ACTIVE_TAB':
return { ...state, activeTab: action.payload };
case 'SET_FIELDS':
return { ...state, fields: action.payload };
case 'UPDATE_FIELD':
return {
...state,
fields: state.fields.map(field =>
field.id === action.payload.id
? { ...field, ...action.payload.updates }
: field
)
};
case 'ADD_FIELD':
return { ...state, fields: [...state.fields, action.payload] };
case 'DELETE_FIELD':
return {
...state,
fields: state.fields.filter(field => field.id !== action.payload)
};
case 'SET_SHOW_SAVE_DIALOG':
return { ...state, showSaveDialog: action.payload };
case 'SET_PENDING_GEOMETRY':
return { ...state, pendingGeometry: action.payload };
default:
return state;
}
}
// 导出初始状态和类型
export { initialState };
export type { DrawEditAction, DrawField, DrawGeometry, DrawEditState };

View File

@@ -1,18 +1,192 @@
'use client';
import { useReducer } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Pen, Edit3, BookOpen, Sparkles } from 'lucide-react';
import {
drawEditReducer,
initialState,
DrawEditState,
DrawGeometry,
DrawField
} from './components/drawEditReducer';
import { AdvancedEditorPromo } from './components/AdvancedEditorPromo';
import { DrawingTools } from './components/DrawingTools';
import { EditingTools } from './components/EditingTools';
import { UsageGuide } from './components/UsageGuide';
import { FieldEntryDialog } from './components/FieldEntryDialog';
import { toast } from 'sonner';
export default function DrawPage() {
const [state, dispatch] = useReducer(drawEditReducer, initialState);
// 处理几何数据保存请求(从绘制工具或编辑工具)
const handleSaveRequest = (geometry: DrawGeometry) => {
dispatch({ type: 'SET_PENDING_GEOMETRY', payload: geometry });
dispatch({ type: 'SET_SHOW_SAVE_DIALOG', payload: true });
};
// 保存地块成功后的回调
const handleFieldSaved = (fieldData: any) => {
toast.success(`地块 ${fieldData.name} 已保存到地块档案`);
dispatch({ type: 'SET_PENDING_GEOMETRY', payload: null });
dispatch({ type: 'SET_SHOW_SAVE_DIALOG', payload: false });
};
// 处理地块更新
const handleFieldsUpdate = (updatedFields: DrawField[]) => {
dispatch({ type: 'SET_FIELDS', payload: updatedFields });
toast.success('地块已更新');
};
// 处理几何变化
const handleGeometryChange = (fieldId: string, geometry: DrawGeometry) => {
// 更新地块的几何属性
const updatedFields = state.fields.map(field => {
if (field.id === fieldId) {
return {
...field,
points: geometry.points,
area: geometry.area || 0,
perimeter: geometry.perimeter || 0
};
}
return field;
});
dispatch({ type: 'SET_FIELDS', payload: updatedFields });
toast.info(`面积:${geometry.area?.toFixed(2)}亩,周长:${geometry.perimeter?.toFixed(0)}`);
};
// 切换到高级编辑器
const handleSwitchToAdvanced = () => {
dispatch({ type: 'SET_SHOW_ADVANCED_EDITOR', payload: true });
};
// 切换标签页
const handleTabChange = (value: 'draw' | 'edit' | 'guide') => {
dispatch({ type: 'SET_ACTIVE_TAB', payload: value });
};
// 高级编辑器
if (state.showAdvancedEditor) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-400"></h2>
<p className="text-muted-foreground">
GIS编辑工具
</p>
</div>
<Button
variant="outline"
onClick={() => dispatch({ type: 'SET_SHOW_ADVANCED_EDITOR', payload: false })}
>
</Button>
</div>
<Card className="p-6 bg-muted">
<div className="text-center space-y-4">
<Sparkles className="w-16 h-16 mx-auto text-green-600" />
<h3></h3>
<p className="text-muted-foreground">
</p>
<Button
onClick={() => dispatch({ type: 'SET_SHOW_ADVANCED_EDITOR', payload: false })}
>
</Button>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold"></h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/map/draw
{/* 高级编辑器推荐卡片 */}
<AdvancedEditorPromo onSwitchToAdvanced={handleSwitchToAdvanced} />
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-400"></h2>
<p className="text-muted-foreground">
线GIS编辑工具
</p>
</div>
</Card>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleSwitchToAdvanced}
>
<Sparkles className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 主内容区 - 标签页 */}
<Tabs value={state.activeTab} onValueChange={handleTabChange}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="draw" className="flex items-center gap-2">
<Pen className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="edit" className="flex items-center gap-2">
<Edit3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="guide" className="flex items-center gap-2">
<BookOpen className="w-4 h-4" />
使
</TabsTrigger>
</TabsList>
{/* 绘制工具标签页 */}
<TabsContent value="draw" className="mt-6">
<DrawingTools
onSaveRequest={handleSaveRequest}
enableSnapping={true}
snapDistance={10}
canvasWidth={800}
canvasHeight={600}
/>
</TabsContent>
{/* 编辑工具标签页 */}
<TabsContent value="edit" className="mt-6">
<EditingTools
fields={state.fields}
onFieldsUpdate={handleFieldsUpdate}
onGeometryChange={handleGeometryChange}
onSaveRequest={handleSaveRequest}
canvasWidth={800}
canvasHeight={600}
/>
</TabsContent>
{/* 使用指南标签页 */}
<TabsContent value="guide" className="mt-6">
<UsageGuide onSwitchToAdvanced={handleSwitchToAdvanced} />
</TabsContent>
</Tabs>
{/* 新增地块对话框 */}
{state.showSaveDialog && state.pendingGeometry && (
<FieldEntryDialog
open={state.showSaveDialog}
onOpenChange={(open) => dispatch({ type: 'SET_SHOW_SAVE_DIALOG', payload: open })}
geometry={state.pendingGeometry}
onSaved={handleFieldSaved}
/>
)}
</div>
);
}