diff --git a/crop-x/src/app/(app)/land-information/archive/context/components/FilterPanel.tsx b/crop-x/src/app/(app)/land-information/archive/context/components/FilterPanel.tsx
new file mode 100644
index 0000000..4d348d0
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/archive/context/components/FilterPanel.tsx
@@ -0,0 +1,167 @@
+'use client';
+
+import { Card } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { 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 (
+
+
+
+
筛选条件
+
+
+
+ {/* 关键词搜索 */}
+
+
+
+
+ onFilterChange('keyword', e.target.value)}
+ className="pl-10"
+ />
+
+
+
+ {/* 土壤类型 */}
+
+
+
+ {soilTypes.map((type) => (
+
onToggleArrayFilter('soilTypes', type.key)}
+ >
+
+ {type.name}
+
+ ))}
+
+
+
+ {/* 种植模式 */}
+
+
+
+ {plantingModes.map((mode) => (
+ onToggleArrayFilter('plantingModes', mode.key)}
+ >
+ {mode.emoji}
+ {mode.name}
+
+ ))}
+
+
+
+ {/* 标签 */}
+
+
+
+ {tags.map((tag) => (
+ onToggleArrayFilter('tags', tag.name)}
+ >
+ {tag.name}
+
+ ))}
+
+
+
+ {/* 面积范围 */}
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/archive/context/components/StatisticsResults.tsx b/crop-x/src/app/(app)/land-information/archive/context/components/StatisticsResults.tsx
new file mode 100644
index 0000000..ba7c14e
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/archive/context/components/StatisticsResults.tsx
@@ -0,0 +1,267 @@
+'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 (
+ <>
+ {/* 基础统计 */}
+
+
+
+
+
统计结果
+
+
+
+
+
+
+ 地块总数
+ {statistics.totalCount}
+
+
+ 总面积
+ {statistics.totalArea.toFixed(2)} 亩
+
+
+ 平均面积
+ {statistics.avgArea.toFixed(2)} 亩
+
+
+ 最大面积
+ {statistics.maxArea.toFixed(2)} 亩
+
+
+ 最小面积
+ {statistics.minArea.toFixed(2)} 亩
+
+
+
+
+ {/* 图表选择 */}
+
+
+
+
+
+ {/* 土壤类型分布 */}
+
+ 土壤类型分布
+ {chartType === 'bar' ? (
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
按地块数量
+
+
+
+ {statistics.soilTypeDistribution.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+
+
按面积
+
+
+
+ {statistics.soilTypeDistribution.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+
+ )}
+
+
+ {/* 种植模式分布 */}
+
+ 种植模式分布
+ {chartType === 'bar' ? (
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
按地块数量
+
+
+
+ {statistics.plantingModeDistribution.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+
+
按面积
+
+
+
+ {statistics.plantingModeDistribution.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+
+ )}
+
+
+ {/* 标签分布 */}
+ {statistics.tagDistribution.length > 0 && (
+
+ 标签分布
+
+ {statistics.tagDistribution.map((tag) => (
+
+
+ {tag.name}
+
+
+
+ ))}
+
+
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/archive/context/components/UsageExamples.tsx b/crop-x/src/app/(app)/land-information/archive/context/components/UsageExamples.tsx
new file mode 100644
index 0000000..261e583
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/archive/context/components/UsageExamples.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { Card } from '@/components/ui/card';
+
+export function UsageExamples() {
+ return (
+
+
+ 💡
+ 使用示例
+
+
+ - • 统计所有沙土且面积大于50亩的地块:选择"沙土",设置最小面积为50
+ - • 统计有机种植的露地地块:选择"露地"种植模式,选择"有机种植"标签
+ - • 统计50-100亩的大棚地块:选择"大棚",设置面积范围50-100
+ - • 多条件组合:可同时选择多个土壤类型、种植模式和标签
+
+
+ );
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/archive/context/components/landStatisticsReducer.tsx b/crop-x/src/app/(app)/land-information/archive/context/components/landStatisticsReducer.tsx
new file mode 100644
index 0000000..9fe1d1d
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/archive/context/components/landStatisticsReducer.tsx
@@ -0,0 +1,187 @@
+'use client';
+
+import { ReactNode } from 'react';
+
+// 类型定义
+export interface LandField {
+ id: string;
+ code: string;
+ name: string;
+ area: number;
+ location: string;
+ soilType: string;
+ plantingMode: string;
+ tags: string[];
+ status: 'active' | 'inactive' | 'pending';
+ description?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface LandTag {
+ id: string;
+ name: string;
+ color: string;
+ description?: string;
+ createdAt: string;
+}
+
+export interface SoilType {
+ id: string;
+ key: string;
+ name: string;
+ color: string;
+}
+
+export interface PlantingMode {
+ id: string;
+ key: string;
+ name: 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: LandField[];
+ tags: LandTag[];
+ soilTypes: SoilType[];
+ plantingModes: PlantingMode[];
+ filters: FilterCondition;
+ statistics: StatisticsResult | null;
+ chartType: 'bar' | 'pie';
+ loading: boolean;
+}
+
+// 初始状态
+export const initialState: LandStatisticsState = {
+ fields: [],
+ tags: [],
+ soilTypes: [],
+ plantingModes: [],
+ filters: {
+ soilTypes: [],
+ plantingModes: [],
+ tags: [],
+ minArea: '',
+ maxArea: '',
+ keyword: '',
+ },
+ statistics: null,
+ chartType: 'bar',
+ loading: false,
+};
+
+// Action 类型定义
+export type LandStatisticsAction =
+ | { type: 'SET_FIELDS'; payload: LandField[] }
+ | { type: 'SET_TAGS'; payload: LandTag[] }
+ | { type: 'SET_SOIL_TYPES'; payload: SoilType[] }
+ | { type: 'SET_PLANTING_MODES'; payload: PlantingMode[] }
+ | { 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' }
+ | { type: 'SET_LOADING'; payload: boolean };
+
+// Reducer 函数
+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 'UPDATE_FILTER':
+ return {
+ ...state,
+ filters: {
+ ...state.filters,
+ [action.payload.key]: action.payload.value,
+ },
+ };
+
+ case 'TOGGLE_ARRAY_FILTER':
+ const { key, value } = action.payload;
+ const currentArray = state.filters[key];
+ const newArray = currentArray.includes(value)
+ ? currentArray.filter(v => v !== value)
+ : [...currentArray, value];
+
+ return {
+ ...state,
+ filters: {
+ ...state.filters,
+ [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 };
+
+ case 'SET_LOADING':
+ return { ...state, loading: action.payload };
+
+ default:
+ return state;
+ }
+}
+
+// Context 类型定义
+export interface LandStatisticsContextType {
+ state: LandStatisticsState;
+ dispatch: React.Dispatch;
+ loadData: (forceReload?: boolean) => void;
+ executeQuery: () => void;
+ exportData: () => void;
+ handleFilterChange: (key: keyof FilterCondition, value: any) => void;
+ handleToggleArrayFilter: (key: 'soilTypes' | 'plantingModes' | 'tags', value: string) => void;
+ handleClearFilters: () => void;
+ handleChartTypeChange: (type: 'bar' | 'pie') => void;
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/archive/context/page.tsx b/crop-x/src/app/(app)/land-information/archive/context/page.tsx
new file mode 100644
index 0000000..46478a0
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/archive/context/page.tsx
@@ -0,0 +1,490 @@
+'use client';
+
+import { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+import {
+ LandStatisticsReducer,
+ initialState,
+ FilterCondition,
+ StatisticsResult,
+ LandStatisticsContextType,
+} from './components/landStatisticsReducer';
+import { FilterPanel } from './components/FilterPanel';
+import { StatisticsResults } from './components/StatisticsResults';
+import { UsageExamples } from './components/UsageExamples';
+
+// Context 创建
+const LandStatisticsContext = createContext(null);
+
+// Provider 组件
+export function LandStatisticsProvider({ children }: { children: ReactNode }) {
+ const [state, dispatch] = useReducer(LandStatisticsReducer, initialState);
+
+ const loadData = (forceReload = false) => {
+ // 加载地块数据
+ const fieldsData = localStorage.getItem('land_archive_data');
+ if (fieldsData && !forceReload) {
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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' as const,
+ 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();
+ 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('数据导出成功');
+ };
+
+ const contextValue: LandStatisticsContextType = {
+ state,
+ dispatch,
+ loadData,
+ executeQuery,
+ exportData,
+ handleFilterChange,
+ handleToggleArrayFilter,
+ handleClearFilters,
+ handleChartTypeChange,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+// Hook
+export function useLandStatistics() {
+ const context = useContext(LandStatisticsContext);
+ if (!context) {
+ throw new Error('useLandStatistics must be used within LandStatisticsProvider');
+ }
+ return context;
+}
+
+export default function LandStatisticsPage() {
+ return (
+
+
+
+ );
+}
+
+function LandStatistics() {
+ const { state, loadData, executeQuery, exportData, handleFilterChange, handleToggleArrayFilter, handleClearFilters, handleChartTypeChange } = useLandStatistics();
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const reloadTestData = () => {
+ localStorage.removeItem('land_archive_data');
+ localStorage.removeItem('land_archive_custom_tags');
+ localStorage.removeItem('land_soil_types');
+ localStorage.removeItem('land_planting_modes');
+ loadData(true);
+ handleClearFilters();
+ toast.success('测试数据已重新加载');
+ };
+
+ return (
+
+
+
+
统计分析
+
+ 灵活的地块筛选和统计查询功能
+
+
+
+
+
+ {/* 筛选条件 */}
+
+
+ {/* 统计结果 */}
+ {state.statistics && (
+
+ )}
+
+ {/* 使用示例 */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/map/spatial-query/components/ExportDialog.tsx b/crop-x/src/app/(app)/land-information/map/spatial-query/components/ExportDialog.tsx
new file mode 100644
index 0000000..291598a
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/map/spatial-query/components/ExportDialog.tsx
@@ -0,0 +1,316 @@
+'use client';
+
+import { useState } from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Textarea } from '@/components/ui/textarea';
+import { Badge } from '@/components/ui/badge';
+import { SpatialField } from './spatialQueryReducer';
+import {
+ Download,
+ FileText,
+ Globe,
+ Database,
+ Code,
+ Copy,
+ CheckCircle
+} from 'lucide-react';
+import { generateGeoJSON, generateKML, generateCSV, generateSQLExample } from './spatialQueryUtils';
+import { toast } from 'sonner';
+
+interface ExportDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ results: SpatialField[];
+ queryType: string;
+ queryGeometry: any;
+ bufferDistance?: number;
+}
+
+export function ExportDialog({
+ open,
+ onOpenChange,
+ results,
+ queryType,
+ queryGeometry,
+ bufferDistance
+}: ExportDialogProps) {
+ const [exportFormat, setExportFormat] = useState<'geojson' | 'kml' | 'csv'>('geojson');
+ const [includeGeometry, setIncludeGeometry] = useState(true);
+ const [includeAttributes, setIncludeAttributes] = useState(true);
+ const [previewContent, setPreviewContent] = useState('');
+ const [showPreview, setShowPreview] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const generateExportData = () => {
+ let data = '';
+
+ switch (exportFormat) {
+ case 'geojson':
+ data = generateGeoJSON(results);
+ break;
+ case 'kml':
+ data = generateKML(results);
+ break;
+ case 'csv':
+ data = generateCSV(results);
+ break;
+ }
+
+ return data;
+ };
+
+ const handlePreview = () => {
+ const data = generateExportData();
+ setPreviewContent(data.substring(0, 1000) + (data.length > 1000 ? '...' : ''));
+ setShowPreview(true);
+ };
+
+ const handleDownload = () => {
+ const data = generateExportData();
+ const blob = new Blob([data], {
+ type: getContentType(exportFormat)
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = getFileName(exportFormat);
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ toast.success(`数据已导出为 ${exportFormat.toUpperCase()} 格式`);
+ onOpenChange(false);
+ };
+
+ const handleCopyToClipboard = () => {
+ const data = generateExportData();
+ navigator.clipboard.writeText(data).then(() => {
+ setCopied(true);
+ toast.success('数据已复制到剪贴板');
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ const getContentType = (format: string): string => {
+ switch (format) {
+ case 'geojson':
+ return 'application/json';
+ case 'kml':
+ return 'application/vnd.google-earth.kml+xml';
+ case 'csv':
+ return 'text/csv';
+ default:
+ return 'text/plain';
+ }
+ };
+
+ const getFileName = (format: string): string => {
+ const timestamp = new Date().toISOString().slice(0, 10);
+ return `spatial_query_results_${timestamp}.${format}`;
+ };
+
+ const formatInfo = {
+ geojson: {
+ name: 'GeoJSON',
+ description: '开放的地理空间数据格式,支持几何和属性信息',
+ icon: Globe,
+ color: 'text-green-600 dark:text-green-400',
+ bgColor: 'bg-green-50 dark:bg-green-950'
+ },
+ kml: {
+ name: 'KML',
+ description: 'Google Earth 支持的地理标记语言',
+ icon: FileText,
+ color: 'text-blue-600 dark:text-blue-400',
+ bgColor: 'bg-blue-50 dark:bg-blue-950'
+ },
+ csv: {
+ name: 'CSV',
+ description: '表格数据格式,适合在Excel等软件中分析',
+ icon: Database,
+ color: 'text-purple-600 dark:text-purple-400',
+ bgColor: 'bg-purple-50 dark:bg-purple-950'
+ }
+ };
+
+ return (
+
+ );
+}
+
+// 获取查询类型名称
+function getQueryTypeName(type: string): string {
+ const names: Record = {
+ 'point-in-polygon': '点在多边形内查询',
+ 'polygon-intersect': '多边形相交查询',
+ 'polygon-adjacent': '多边形相邻查询',
+ 'buffer': '缓冲区查询'
+ };
+ return names[type] || type;
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/map/spatial-query/components/MapPicker.tsx b/crop-x/src/app/(app)/land-information/map/spatial-query/components/MapPicker.tsx
new file mode 100644
index 0000000..536af8e
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/map/spatial-query/components/MapPicker.tsx
@@ -0,0 +1,491 @@
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card } from '@/components/ui/card';
+import { SpatialQueryState, SpatialQueryAction } from './spatialQueryReducer';
+import {
+ MapPin,
+ Shapes,
+ MousePointer,
+ RefreshCw,
+ Check,
+ X,
+ Navigation
+} from 'lucide-react';
+
+interface MapPickerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ mode: 'point' | 'polygon';
+ state: SpatialQueryState;
+ dispatch: React.Dispatch;
+ onConfirm: (result: { lat: number; lng: number } | number[][]) => void;
+}
+
+export function MapPicker({
+ open,
+ onOpenChange,
+ mode,
+ state,
+ dispatch,
+ onConfirm
+}: MapPickerProps) {
+ const canvasRef = useRef(null);
+ const [isDrawing, setIsDrawing] = useState(false);
+ const [currentPoint, setCurrentPoint] = useState<{ lat: number; lng: number } | null>(null);
+ const [polygonPoints, setPolygonPoints] = useState([]);
+ const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [mapOffset, setMapOffset] = useState({ x: 0, y: 0 });
+ const [mapScale, setMapScale] = useState(1);
+
+ // 模拟地图中心坐标(北京天安门)
+ const mapCenter = { lat: 39.9042, lng: 116.4074 };
+ const mapBounds = {
+ north: 39.9100,
+ south: 39.8984,
+ east: 116.4200,
+ west: 116.3948
+ };
+
+ useEffect(() => {
+ if (open && canvasRef.current) {
+ drawMap();
+ }
+ }, [open, currentPoint, polygonPoints, mousePos, mapOffset, mapScale]);
+
+ const drawMap = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const width = canvas.width;
+ const height = canvas.height;
+
+ // 清空画布
+ ctx.fillStyle = '#f3f4f6';
+ ctx.fillRect(0, 0, width, height);
+
+ // 保存变换状态
+ ctx.save();
+
+ // 应用偏移和缩放
+ ctx.translate(mapOffset.x, mapOffset.y);
+ ctx.scale(mapScale, mapScale);
+
+ // 绘制网格背景
+ drawGrid(ctx, width, height);
+
+ // 绘制模拟地块
+ drawMockFields(ctx, width, height);
+
+ // 绘制查询几何图形
+ if (mode === 'point' && currentPoint) {
+ drawPoint(ctx, currentPoint, width, height);
+ }
+
+ if (mode === 'polygon' && polygonPoints.length > 0) {
+ drawPolygon(ctx, polygonPoints, width, height);
+ }
+
+ // 绘制鼠标预览
+ if (mousePos && mode === 'polygon' && isDrawing) {
+ drawPolygonPreview(ctx, polygonPoints, mousePos, width, height);
+ }
+
+ // 恢复变换状态
+ ctx.restore();
+
+ // 绘制UI元素(不受变换影响)
+ drawUIElements(ctx, width, height);
+ };
+
+ const drawGrid = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
+ ctx.strokeStyle = '#e5e7eb';
+ ctx.lineWidth = 1;
+
+ const gridSize = 50 * mapScale;
+ const startX = (mapOffset.x % gridSize + gridSize) % gridSize;
+ const startY = (mapOffset.y % gridSize + gridSize) % gridSize;
+
+ for (let x = startX; x < width; x += gridSize) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ }
+
+ for (let y = startY; y < height; y += gridSize) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+ };
+
+ const drawMockFields = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
+ // 模拟3个地块的位置和形状
+ const fields = [
+ {
+ points: [[100, 100], [200, 100], [200, 180], [100, 180]],
+ color: '#22c55e',
+ name: '东区沙质土试验田'
+ },
+ {
+ points: [[250, 120], [350, 120], [350, 200], [250, 200]],
+ color: '#3b82f6',
+ name: '西区黏质土示范区'
+ },
+ {
+ points: [[150, 250], [250, 250], [250, 330], [150, 330]],
+ color: '#f97316',
+ name: '南区壤质土生产基地'
+ }
+ ];
+
+ fields.forEach(field => {
+ // 绘制地块多边形
+ ctx.fillStyle = field.color + '30';
+ ctx.strokeStyle = field.color;
+ ctx.lineWidth = 2;
+
+ ctx.beginPath();
+ ctx.moveTo(field.points[0][0], field.points[0][1]);
+ field.points.forEach(point => {
+ ctx.lineTo(point[0], point[1]);
+ });
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+
+ // 绘制地块名称
+ ctx.fillStyle = '#1f2937';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'center';
+ const centerX = field.points.reduce((sum, p) => sum + p[0], 0) / field.points.length;
+ const centerY = field.points.reduce((sum, p) => sum + p[1], 0) / field.points.length;
+ ctx.fillText(field.name, centerX, centerY);
+ });
+ };
+
+ const drawPoint = (ctx: CanvasRenderingContext2D, point: { lat: number; lng: number }, width: number, height: number) => {
+ const canvasCoords = latLngToCanvas(point.lat, point.lng, width, height);
+
+ // 绘制点
+ ctx.fillStyle = '#ef4444';
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 2;
+
+ ctx.beginPath();
+ ctx.arc(canvasCoords.x, canvasCoords.y, 8, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.stroke();
+
+ // 绘制中心点
+ ctx.fillStyle = '#ffffff';
+ ctx.beginPath();
+ ctx.arc(canvasCoords.x, canvasCoords.y, 2, 0, 2 * Math.PI);
+ ctx.fill();
+ };
+
+ const drawPolygon = (ctx: CanvasRenderingContext2D, points: number[][], width: number, height: number) => {
+ if (points.length === 0) return;
+
+ ctx.fillStyle = '#3b82f630';
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = 2;
+
+ ctx.beginPath();
+ ctx.moveTo(points[0][0], points[0][1]);
+ points.forEach(point => {
+ ctx.lineTo(point[0], point[1]);
+ });
+ if (points.length > 2) {
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.stroke();
+
+ // 绘制顶点
+ points.forEach((point, index) => {
+ ctx.fillStyle = '#3b82f6';
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 2;
+
+ ctx.beginPath();
+ ctx.arc(point[0], point[1], 5, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.stroke();
+
+ // 绘制顶点编号
+ ctx.fillStyle = '#1f2937';
+ ctx.font = '10px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText((index + 1).toString(), point[0], point[1] - 10);
+ });
+ };
+
+ const drawPolygonPreview = (ctx: CanvasRenderingContext2D, points: number[][], mousePos: { x: number; y: number }, width: number, height: number) => {
+ if (points.length === 0) return;
+
+ ctx.strokeStyle = '#3b82f680';
+ ctx.lineWidth = 1;
+ ctx.setLineDash([5, 5]);
+
+ ctx.beginPath();
+ if (points.length > 0) {
+ ctx.moveTo(points[points.length - 1][0], points[points.length - 1][1]);
+ ctx.lineTo(mousePos.x, mousePos.y);
+ }
+ ctx.stroke();
+
+ ctx.setLineDash([]);
+ };
+
+ const drawUIElements = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
+ // 绘制坐标信息
+ if (mousePos) {
+ const latLng = canvasToLatLng(mousePos.x, mousePos.y, width, height);
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
+ ctx.fillRect(mousePos.x + 10, mousePos.y - 30, 120, 25);
+ ctx.fillStyle = '#ffffff';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'left';
+ ctx.fillText(`${latLng.lat.toFixed(4)}, ${latLng.lng.toFixed(4)}`, mousePos.x + 15, mousePos.y - 15);
+ }
+
+ // 绘制操作提示
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
+ ctx.fillRect(10, 10, 200, 60);
+ ctx.fillStyle = '#ffffff';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'left';
+
+ let instructions = '';
+ if (mode === 'point') {
+ instructions = '单击地图选择查询点\n滚轮缩放,拖拽移动地图';
+ } else {
+ instructions = `单击添加多边形顶点 (${polygonPoints.length}个)\n右键完成绘制,滚轮缩放,拖拽移动`;
+ }
+
+ instructions.split('\n').forEach((line, index) => {
+ ctx.fillText(line, 15, 30 + index * 15);
+ });
+ };
+
+ const latLngToCanvas = (lat: number, lng: number, width: number, height: number) => {
+ const x = ((lng - mapBounds.west) / (mapBounds.east - mapBounds.west)) * width;
+ const y = ((mapBounds.north - lat) / (mapBounds.north - mapBounds.south)) * height;
+ return { x, y };
+ };
+
+ const canvasToLatLng = (x: number, y: number, width: number, height: number) => {
+ const lng = (x / width) * (mapBounds.east - mapBounds.west) + mapBounds.west;
+ const lat = mapBounds.north - (y / height) * (mapBounds.north - mapBounds.south);
+ return { lat, lng };
+ };
+
+ const handleCanvasClick = (e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+ const latLng = canvasToLatLng(x, y, canvas.width, canvas.height);
+
+ if (mode === 'point') {
+ setCurrentPoint(latLng);
+ } else if (mode === 'polygon') {
+ if (e.button === 0) { // 左键添加顶点
+ setPolygonPoints([...polygonPoints, [x, y]]);
+ setIsDrawing(true);
+ }
+ }
+ };
+
+ const handleCanvasRightClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (mode === 'polygon' && polygonPoints.length >= 3) {
+ // 完成多边形绘制
+ setIsDrawing(false);
+ }
+ };
+
+ const handleCanvasMouseMove = (e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ setMousePos({ x, y });
+
+ if (isDragging) {
+ setMapOffset({
+ x: x - (mousePos?.x || 0),
+ y: y - (mousePos?.y || 0)
+ });
+ }
+ };
+
+ const handleCanvasMouseDown = (e: React.MouseEvent) => {
+ if (e.button === 1 || (e.button === 0 && e.shiftKey)) { // 中键或Shift+左键拖拽
+ e.preventDefault();
+ setIsDragging(true);
+ }
+ };
+
+ const handleCanvasMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ const handleCanvasWheel = (e: React.WheelEvent) => {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
+ setMapScale(prev => Math.max(0.5, Math.min(3, prev * delta)));
+ };
+
+ const handleConfirm = () => {
+ if (mode === 'point' && currentPoint) {
+ onConfirm(currentPoint);
+ } else if (mode === 'polygon' && polygonPoints.length >= 3) {
+ // 转换为坐标数组
+ const coordinates = polygonPoints.map(point => {
+ const latLng = canvasToLatLng(point[0], point[1], 600, 400);
+ return [latLng.lng, latLng.lat];
+ });
+ onConfirm(coordinates);
+ }
+ };
+
+ const handleReset = () => {
+ setCurrentPoint(null);
+ setPolygonPoints([]);
+ setIsDrawing(false);
+ setMapOffset({ x: 0, y: 0 });
+ setMapScale(1);
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/map/spatial-query/components/QueryPanel.tsx b/crop-x/src/app/(app)/land-information/map/spatial-query/components/QueryPanel.tsx
new file mode 100644
index 0000000..5c1393a
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/map/spatial-query/components/QueryPanel.tsx
@@ -0,0 +1,393 @@
+'use client';
+
+import { Card } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { SpatialQueryState, SpatialQueryAction } from './spatialQueryReducer';
+import {
+ MapPin,
+ Shapes,
+ Circle,
+ Search,
+ Map,
+ RefreshCw,
+ Download,
+ Code,
+ Database
+} from 'lucide-react';
+
+interface QueryPanelProps {
+ state: SpatialQueryState;
+ dispatch: React.Dispatch;
+ onExecuteQuery: () => void;
+ onShowMapPicker: () => void;
+}
+
+export function QueryPanel({ state, dispatch, onExecuteQuery, onShowMapPicker }: QueryPanelProps) {
+ return (
+
+ {/* 查询类型选择 */}
+
+
+
+
+
空间查询类型
+
+
+
+
+
+
+ {state.queryType === 'point-in-polygon' && '查询指定坐标点位于哪些地块范围内'}
+ {state.queryType === 'polygon-intersect' && '查询与指定多边形相交的所有地块'}
+ {state.queryType === 'polygon-adjacent' && '查询与指定多边形相邻(共享边界)的地块'}
+ {state.queryType === 'buffer' && '查询指定点周围一定距离范围内的地块'}
+
+
+
+
+
+ {/* 查询参数设置 */}
+
+
+
查询参数
+
+ {state.queryType === 'point-in-polygon' && (
+
+
+
+
+
+ )}
+
+ {(state.queryType === 'polygon-intersect' || state.queryType === 'polygon-adjacent') && (
+
+
+ {state.queryPolygon && state.queryPolygon.length > 0 ? (
+
+
+
+ 已选择多边形 ({state.queryPolygon.length} 个顶点)
+
+
+
+
+ 顶点坐标已保存,可在地图上预览
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ {state.queryType === 'buffer' && (
+
+
+
+
+
+ dispatch({
+ type: 'SET_BUFFER_DISTANCE',
+ payload: parseInt(e.target.value) || 100
+ })}
+ />
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {/* 导出选项 */}
+ {state.queryResult && state.queryResult.length > 0 && (
+
+
+
+
+
导出查询结果
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* SQL示例 */}
+ {state.queryResult && (
+
+
+
+
+
PostGIS SQL示例
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// 检查查询参数是否有效
+function isQueryValid(state: SpatialQueryState): boolean {
+ switch (state.queryType) {
+ case 'point-in-polygon':
+ return state.selectedPoint !== null;
+ case 'polygon-intersect':
+ case 'polygon-adjacent':
+ return state.queryPolygon !== null && state.queryPolygon.length >= 3;
+ case 'buffer':
+ return state.selectedPoint !== null && state.bufferDistance > 0;
+ default:
+ return false;
+ }
+}
+
+// 生成SQL示例的简化版本
+function generateSQLExample(state: SpatialQueryState): string {
+ switch (state.queryType) {
+ case 'point-in-polygon':
+ return `-- 点在多边形内查询
+SELECT * FROM fields
+WHERE ST_Contains(
+ geometry,
+ ST_GeomFromText('POINT(${state.selectedPoint?.lng} ${state.selectedPoint?.lat})', 4326)
+);`;
+
+ case 'polygon-intersect':
+ return `-- 多边形相交查询
+SELECT * FROM fields
+WHERE ST_Intersects(
+ geometry,
+ ST_GeomFromText('POLYGON((...))', 4326)
+);`;
+
+ case 'polygon-adjacent':
+ return `-- 多边形相邻查询
+SELECT * FROM fields
+WHERE ST_Touches(
+ geometry,
+ ST_GeomFromText('POLYGON((...))', 4326)
+);`;
+
+ case 'buffer':
+ return `-- 缓冲区查询
+SELECT * FROM fields
+WHERE ST_DWithin(
+ geometry,
+ ST_GeomFromText('POINT(${state.selectedPoint?.lng} ${state.selectedPoint?.lat})', 4326),
+ ${state.bufferDistance}
+);`;
+
+ default:
+ return '-- 请选择查询类型';
+ }
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/map/spatial-query/components/ResultsPanel.tsx b/crop-x/src/app/(app)/land-information/map/spatial-query/components/ResultsPanel.tsx
new file mode 100644
index 0000000..bcaba29
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/map/spatial-query/components/ResultsPanel.tsx
@@ -0,0 +1,344 @@
+'use client';
+
+import { Card } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { SpatialField } from './spatialQueryReducer';
+import {
+ MapPin,
+ AreaChart,
+ Ruler,
+ User,
+ Calendar,
+ RefreshCw,
+ Eye,
+ FileText,
+ Database
+} from 'lucide-react';
+
+interface ResultsPanelProps {
+ results: SpatialField[] | null;
+ isLoading: boolean;
+ onViewOnMap: (fields: SpatialField[]) => void;
+ onExportData: () => void;
+ onClearResults: () => void;
+}
+
+export function ResultsPanel({ results, isLoading, onViewOnMap, onExportData, onClearResults }: ResultsPanelProps) {
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!results || results.length === 0) {
+ return (
+
+
+
+
+
暂无查询结果
+
+ 请设置查询参数并执行查询,这里将显示查询到的地块信息
+
+
+
+
+ );
+ }
+
+ // 计算统计信息
+ const totalArea = results.reduce((sum, field) => sum + field.area, 0);
+ const totalPerimeter = results.reduce((sum, field) => sum + field.perimeter, 0);
+ const soilTypes = [...new Set(results.map(field => field.soilType))];
+ const plantingModes = [...new Set(results.map(field => field.plantingMode))];
+
+ return (
+
+ {/* 查询结果统计 */}
+
+
+
+
查询结果统计
+
+ {results.length} 个地块
+
+
+
+
+
+
+
+ {totalArea.toFixed(1)}
+
+
亩
+
+
+
+
+
+ 总周长
+
+
+ {totalPerimeter.toFixed(0)}
+
+
米
+
+
+
+
+
+ 土壤类型
+
+
+ {soilTypes.length}
+
+
种类型
+
+
+
+
+
+ 种植模式
+
+
+ {plantingModes.length}
+
+
种模式
+
+
+
+
+
+
+
+
+
+
+
+ {/* 地块详细列表 */}
+
+
+
地块详细信息
+
+
+ {results.map((field, index) => (
+
+
+
+
{field.name}
+
{field.code}
+
+
+ #{index + 1}
+
+
+
+
+
+
面积
+
+ {field.area.toFixed(1)} 亩
+
+
+
+
周长
+
+ {field.perimeter.toFixed(0)} 米
+
+
+
+
中心坐标
+
+ {field.centroid.lat.toFixed(4)}, {field.centroid.lng.toFixed(4)}
+
+
+
+
状态
+
+
+ {field.status === 'active' ? '活跃' : '未激活'}
+
+
+
+
+
+
+
+
+
+ 土壤类型:
+
+ {getSoilTypeName(field.soilType)}
+
+
+
+ 种植模式:
+
+ {getPlantingModeName(field.plantingMode)}
+
+
+
+
+ 负责人:
+ {field.owner}
+
+
+
+
+
+ 创建时间: {field.createdAt}
+
+
+ ))}
+
+
+
+
+ {/* 分类统计 */}
+
+
+
分类统计
+
+
+ {/* 土壤类型分布 */}
+
+
土壤类型分布
+
+ {soilTypes.map(soilType => {
+ const fields = results.filter(field => field.soilType === soilType);
+ const area = fields.reduce((sum, field) => sum + field.area, 0);
+ const percentage = (area / totalArea * 100).toFixed(1);
+
+ return (
+
+
+
+
{getSoilTypeName(soilType)}
+
+
+
{area.toFixed(1)} 亩
+
{percentage}%
+
+
+ );
+ })}
+
+
+
+ {/* 种植模式分布 */}
+
+
种植模式分布
+
+ {plantingModes.map(mode => {
+ const fields = results.filter(field => field.plantingMode === mode);
+ const area = fields.reduce((sum, field) => sum + field.area, 0);
+ const percentage = (area / totalArea * 100).toFixed(1);
+
+ return (
+
+
+
+
{getPlantingModeName(mode)}
+
+
+
{area.toFixed(1)} 亩
+
{percentage}%
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
+
+// 获取土壤类型名称
+function getSoilTypeName(type: string): string {
+ const soilTypes: Record = {
+ 'sandy': '沙质土',
+ 'clay': '黏质土',
+ 'loamy': '壤质土',
+ 'peat': '泥炭土',
+ 'saline': '盐碱土',
+ 'silt': '粉质土',
+ 'rocky': '岩石土'
+ };
+ return soilTypes[type] || type;
+}
+
+// 获取土壤类型颜色
+function getSoilTypeColor(type: string): string {
+ const colors: Record = '#fbbf24'; // 黄色
+ const colorMap: Record = {
+ 'sandy': '#fbbf24', // 黄色
+ 'clay': '#a78bfa', // 紫色
+ 'loamy': '#60a5fa', // 蓝色
+ 'peat': '#8b5cf6', // 深紫色
+ 'saline': '#f87171', // 红色
+ 'silt': '#34d399', // 绿色
+ 'rocky': '#6b7280' // 灰色
+ };
+ return colorMap[type] || '#6b7280';
+}
+
+// 获取种植模式名称
+function getPlantingModeName(mode: string): string {
+ const modes: Record = {
+ 'conventional': '传统种植',
+ 'organic': '有机种植',
+ 'greenhouse': '温室种植',
+ 'hydroponic': '水培种植',
+ 'aeroponic': '气培种植'
+ };
+ return modes[mode] || mode;
+}
+
+// 获取种植模式颜色
+function getPlantingModeColor(mode: string): string {
+ const colorMap: Record = {
+ 'conventional': '#60a5fa', // 蓝色
+ 'organic': '#34d399', // 绿色
+ 'greenhouse': '#fbbf24', // 黄色
+ 'hydroponic': '#a78bfa', // 紫色
+ 'aeroponic': '#f87171' // 红色
+ };
+ return colorMap[mode] || '#6b7280';
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/map/spatial-query/components/spatialQueryReducer.tsx b/crop-x/src/app/(app)/land-information/map/spatial-query/components/spatialQueryReducer.tsx
new file mode 100644
index 0000000..212aa3e
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/map/spatial-query/components/spatialQueryReducer.tsx
@@ -0,0 +1,260 @@
+'use client';
+
+import { useReducer } from 'react';
+
+// 地块数据接口
+export interface SpatialField {
+ id: string;
+ code: string;
+ name: string;
+ area: number;
+ perimeter: number;
+ centroid: { lat: number; lng: number };
+ bounds: {
+ north: number;
+ south: number;
+ east: number;
+ west: number;
+ };
+ geometry: {
+ type: 'Polygon';
+ coordinates: number[][][]; // [[[lng, lat], ...], ...]
+ };
+ soilType: string;
+ plantingMode: string;
+ status: string;
+ owner: string;
+ createdAt: string;
+}
+
+// 空间查询状态接口
+export interface SpatialQueryState {
+ queryType: 'point-in-polygon' | 'polygon-intersect' | 'polygon-adjacent' | 'buffer';
+ queryResult: any;
+ queryGeometry: any;
+ showGeometryDialog: boolean;
+ bufferDistance: number;
+ selectedPoint: { lat: number; lng: number } | null;
+ queryPolygon: number[][] | null;
+ selectedFields: string[];
+ showMapPicker: boolean;
+ mapPickerMode: 'point' | 'polygon';
+ isQuerying: boolean;
+ exportFormat: 'geojson' | 'kml' | 'csv';
+ showExportDialog: boolean;
+ activeTab: 'query' | 'analysis' | 'tools';
+ calculationType: 'area' | 'perimeter' | 'centroid' | 'distance' | 'bounds';
+ distanceStart: { lat: number; lng: number } | null;
+ distanceEnd: { lat: number; lng: number } | null;
+ analysisFields: string[];
+}
+
+// Action类型
+export type SpatialQueryAction =
+ | { type: 'SET_QUERY_TYPE'; payload: SpatialQueryState['queryType'] }
+ | { type: 'SET_QUERY_RESULT'; payload: any }
+ | { type: 'SET_QUERY_GEOMETRY'; payload: any }
+ | { type: 'SET_SHOW_GEOMETRY_DIALOG'; payload: boolean }
+ | { type: 'SET_BUFFER_DISTANCE'; payload: number }
+ | { type: 'SET_SELECTED_POINT'; payload: { lat: number; lng: number } | null }
+ | { type: 'SET_QUERY_POLYGON'; payload: number[][] | null }
+ | { type: 'SET_SELECTED_FIELDS'; payload: string[] }
+ | { type: 'SET_SHOW_MAP_PICKER'; payload: boolean }
+ | { type: 'SET_MAP_PICKER_MODE'; payload: 'point' | 'polygon' }
+ | { type: 'SET_IS_QUERYING'; payload: boolean }
+ | { type: 'SET_EXPORT_FORMAT'; payload: 'geojson' | 'kml' | 'csv' }
+ | { type: 'SET_SHOW_EXPORT_DIALOG'; payload: boolean }
+ | { type: 'SET_ACTIVE_TAB'; payload: 'query' | 'analysis' | 'tools' }
+ | { type: 'SET_CALCULATION_TYPE'; payload: 'area' | 'perimeter' | 'centroid' | 'distance' | 'bounds' }
+ | { type: 'SET_DISTANCE_START'; payload: { lat: number; lng: number } | null }
+ | { type: 'SET_DISTANCE_END'; payload: { lat: number; lng: number } | null }
+ | { type: 'SET_ANALYSIS_FIELDS'; payload: string[] }
+ | { type: 'RESET_QUERY' };
+
+// 模拟地块数据
+const mockFields: SpatialField[] = [
+ {
+ id: '1',
+ code: 'TD001',
+ name: '东区沙质土试验田',
+ area: 85.5,
+ perimeter: 1250,
+ centroid: { lat: 39.9042, lng: 116.4074 },
+ bounds: { north: 39.905, south: 39.903, east: 116.408, west: 116.406 },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [116.406, 39.903],
+ [116.408, 39.903],
+ [116.408, 39.905],
+ [116.406, 39.905],
+ [116.406, 39.903]
+ ]]
+ },
+ soilType: 'sandy',
+ plantingMode: 'conventional',
+ status: 'active',
+ owner: '张三',
+ createdAt: '2024-01-15'
+ },
+ {
+ id: '2',
+ code: 'TD002',
+ name: '西区黏质土示范区',
+ area: 120.8,
+ perimeter: 1680,
+ centroid: { lat: 39.9048, lng: 116.4082 },
+ bounds: { north: 39.906, south: 39.904, east: 116.409, west: 116.407 },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [116.407, 39.904],
+ [116.409, 39.904],
+ [116.409, 39.906],
+ [116.407, 39.906],
+ [116.407, 39.904]
+ ]]
+ },
+ soilType: 'clay',
+ plantingMode: 'organic',
+ status: 'active',
+ owner: '李四',
+ createdAt: '2024-01-20'
+ },
+ {
+ id: '3',
+ code: 'TD003',
+ name: '南区壤质土生产基地',
+ area: 95.2,
+ perimeter: 1420,
+ centroid: { lat: 39.9038, lng: 116.4068 },
+ bounds: { north: 39.904, south: 39.903, east: 116.407, west: 116.406 },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [116.406, 39.903],
+ [116.407, 39.903],
+ [116.407, 39.904],
+ [116.406, 39.904],
+ [116.406, 39.903]
+ ]]
+ },
+ soilType: 'loamy',
+ plantingMode: 'greenhouse',
+ status: 'active',
+ owner: '王五',
+ createdAt: '2024-01-25'
+ }
+];
+
+// 初始状态
+const initialState: SpatialQueryState = {
+ queryType: 'point-in-polygon',
+ queryResult: null,
+ queryGeometry: null,
+ showGeometryDialog: false,
+ bufferDistance: 100,
+ selectedPoint: null,
+ queryPolygon: null,
+ selectedFields: [],
+ showMapPicker: false,
+ mapPickerMode: 'point',
+ isQuerying: false,
+ exportFormat: 'geojson',
+ showExportDialog: false,
+ activeTab: 'query',
+ calculationType: 'area',
+ distanceStart: null,
+ distanceEnd: null,
+ analysisFields: [],
+};
+
+// Reducer函数
+export function spatialQueryReducer(state: SpatialQueryState, action: SpatialQueryAction): SpatialQueryState {
+ switch (action.type) {
+ case 'SET_QUERY_TYPE':
+ return {
+ ...state,
+ queryType: action.payload,
+ queryResult: null,
+ selectedPoint: null,
+ queryPolygon: null,
+ selectedFields: []
+ };
+
+ case 'SET_QUERY_RESULT':
+ return { ...state, queryResult: action.payload };
+
+ case 'SET_QUERY_GEOMETRY':
+ return { ...state, queryGeometry: action.payload };
+
+ case 'SET_SHOW_GEOMETRY_DIALOG':
+ return { ...state, showGeometryDialog: action.payload };
+
+ case 'SET_BUFFER_DISTANCE':
+ return { ...state, bufferDistance: action.payload };
+
+ case 'SET_SELECTED_POINT':
+ return { ...state, selectedPoint: action.payload };
+
+ case 'SET_QUERY_POLYGON':
+ return { ...state, queryPolygon: action.payload };
+
+ case 'SET_SELECTED_FIELDS':
+ return { ...state, selectedFields: action.payload };
+
+ case 'SET_SHOW_MAP_PICKER':
+ return { ...state, showMapPicker: action.payload };
+
+ case 'SET_MAP_PICKER_MODE':
+ return { ...state, mapPickerMode: action.payload };
+
+ case 'SET_IS_QUERYING':
+ return { ...state, isQuerying: action.payload };
+
+ case 'SET_EXPORT_FORMAT':
+ return { ...state, exportFormat: action.payload };
+
+ case 'SET_SHOW_EXPORT_DIALOG':
+ return { ...state, showExportDialog: action.payload };
+
+ case 'SET_ACTIVE_TAB':
+ return {
+ ...state,
+ activeTab: action.payload,
+ queryResult: null
+ };
+
+ case 'SET_CALCULATION_TYPE':
+ return { ...state, calculationType: action.payload };
+
+ case 'SET_DISTANCE_START':
+ return { ...state, distanceStart: action.payload };
+
+ case 'SET_DISTANCE_END':
+ return { ...state, distanceEnd: action.payload };
+
+ case 'SET_ANALYSIS_FIELDS':
+ return { ...state, analysisFields: action.payload };
+
+ case 'RESET_QUERY':
+ return {
+ ...state,
+ queryResult: null,
+ selectedPoint: null,
+ queryPolygon: null,
+ selectedFields: [],
+ queryGeometry: null,
+ distanceStart: null,
+ distanceEnd: null,
+ analysisFields: []
+ };
+
+ default:
+ return state;
+ }
+}
+
+// 导出初始状态和类型
+export { initialState, mockFields };
+export type { SpatialQueryAction, SpatialField, SpatialQueryState };
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/map/spatial-query/components/spatialQueryUtils.tsx b/crop-x/src/app/(app)/land-information/map/spatial-query/components/spatialQueryUtils.tsx
new file mode 100644
index 0000000..402d8b4
--- /dev/null
+++ b/crop-x/src/app/(app)/land-information/map/spatial-query/components/spatialQueryUtils.tsx
@@ -0,0 +1,466 @@
+/**
+ * 空间查询工具函数
+ * 实现PostGIS风格的空间查询和几何计算功能
+ */
+
+import { SpatialField } from './spatialQueryReducer';
+
+// 地球半径(米)
+const EARTH_RADIUS = 6371000;
+
+// 将角度转换为弧度
+const toRadians = (degrees: number): number => degrees * Math.PI / 180;
+
+// 将弧度转换为角度
+const toDegrees = (radians: number): number => radians * 180 / Math.PI;
+
+/**
+ * 计算两点之间的距离(Haversine公式)
+ */
+export function calculateDistance(
+ lat1: number, lng1: number,
+ lat2: number, lng2: number
+): number {
+ const dLat = toRadians(lat2 - lat1);
+ const dLng = toRadians(lng2 - lng1);
+
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
+ Math.sin(dLng / 2) * Math.sin(dLng / 2);
+
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+
+ return EARTH_RADIUS * c; // 返回米
+}
+
+/**
+ * 判断点是否在多边形内(射线法)
+ */
+export function isPointInPolygon(
+ point: { lat: number; lng: number },
+ polygon: number[][]
+): boolean {
+ let inside = false;
+ const x = point.lng;
+ const y = point.lat;
+
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const xi = polygon[i][0], yi = polygon[i][1];
+ const xj = polygon[j][0], yj = polygon[j][1];
+
+ const intersect = ((yi > y) !== (yj > y))
+ && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
+ if (intersect) inside = !inside;
+ }
+
+ return inside;
+}
+
+/**
+ * 判断两个多边形是否相交
+ */
+export function doPolygonsIntersect(
+ polygon1: number[][],
+ polygon2: number[][]
+): boolean {
+ // 简化判断:检查是否有顶点在另一个多边形内
+ for (const point of polygon1) {
+ if (isPointInPolygon({ lat: point[1], lng: point[0] }, polygon2)) {
+ return true;
+ }
+ }
+
+ for (const point of polygon2) {
+ if (isPointInPolygon({ lat: point[1], lng: point[0] }, polygon1)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * 判断两个多边形是否相邻(共享边界)
+ */
+export function arePolygonsAdjacent(
+ polygon1: number[][],
+ polygon2: number[][]
+): boolean {
+ // 检查是否有共享的边界点(简化实现)
+ for (let i = 0; i < polygon1.length - 1; i++) {
+ for (let j = 0; j < polygon2.length - 1; j++) {
+ // 检查是否有相同的边
+ const edge1 = [polygon1[i], polygon1[i + 1]];
+ const edge2 = [polygon2[j], polygon2[j + 1]];
+
+ if ((edgesEqual(edge1, edge2) || edgesEqual(edge1, [edge2[1], edge2[0]]))) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+function edgesEqual(edge1: number[][], edge2: number[][]): boolean {
+ return (
+ (pointsEqual(edge1[0], edge2[0]) && pointsEqual(edge1[1], edge2[1])) ||
+ (pointsEqual(edge1[0], edge2[1]) && pointsEqual(edge1[1], edge2[0]))
+ );
+}
+
+function pointsEqual(p1: number[], p2: number[]): boolean {
+ return Math.abs(p1[0] - p2[0]) < 1e-9 && Math.abs(p1[1] - p2[1]) < 1e-9;
+}
+
+/**
+ * 创建缓冲区(简化的圆形缓冲区)
+ */
+export function createBuffer(
+ center: { lat: number; lng: number },
+ radius: number // 米
+): number[][] {
+ const points: number[][] = [];
+ const numPoints = 36; // 36个点形成近似圆形
+
+ for (let i = 0; i < numPoints; i++) {
+ const angle = (i / numPoints) * 2 * Math.PI;
+ const bearing = toDegrees(angle);
+
+ const point = calculateDestinationPoint(center, bearing, radius);
+ points.push([point.lng, point.lat]);
+ }
+
+ return points;
+}
+
+/**
+ * 计算目标点(从起点按方位角和距离计算)
+ */
+function calculateDestinationPoint(
+ start: { lat: number; lng: number },
+ bearing: number,
+ distance: number
+): { lat: number; lng: number } {
+ const lat1 = toRadians(start.lat);
+ const lng1 = toRadians(start.lng);
+ const brng = toRadians(bearing);
+
+ const angularDistance = distance / EARTH_RADIUS;
+
+ const lat2 = Math.asin(
+ Math.sin(lat1) * Math.cos(angularDistance) +
+ Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(brng)
+ );
+
+ const lng2 = lng1 + Math.atan2(
+ Math.sin(brng) * Math.sin(angularDistance) * Math.cos(lat1),
+ Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat2)
+ );
+
+ return {
+ lat: toDegrees(lat2),
+ lng: toDegrees(lng2)
+ };
+}
+
+/**
+ * 计算多边形面积(L'Huilier定理,球面几何)
+ */
+export function calculatePolygonArea(polygon: number[][]): number {
+ if (polygon.length < 3) return 0;
+
+ let totalArea = 0;
+
+ // 将多边形分割为三角形计算面积
+ for (let i = 1; i < polygon.length - 1; i++) {
+ const triangle = [polygon[0], polygon[i], polygon[i + 1]];
+ const area = calculateTriangleArea(triangle);
+ totalArea += area;
+ }
+
+ return totalArea;
+}
+
+/**
+ * 计算三角形面积(球面几何)
+ */
+function calculateTriangleArea(triangle: number[][]): number {
+ const [a, b, c] = triangle;
+
+ // 将经纬度转换为笛卡尔坐标进行计算(简化实现)
+ const area = Math.abs(
+ (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1])) / 2
+ );
+
+ // 转换为平方米(粗略转换)
+ return area * 111320 * 111320; // 1度 ≈ 111320米
+}
+
+/**
+ * 计算多边形周长
+ */
+export function calculatePolygonPerimeter(polygon: number[][]): number {
+ if (polygon.length < 2) return 0;
+
+ let perimeter = 0;
+
+ for (let i = 0; i < polygon.length - 1; i++) {
+ const p1 = polygon[i];
+ const p2 = polygon[i + 1];
+ perimeter += calculateDistance(p1[1], p1[0], p2[1], p2[0]);
+ }
+
+ // 闭合多边形,计算最后一条边
+ if (polygon.length > 2) {
+ const first = polygon[0];
+ const last = polygon[polygon.length - 1];
+ perimeter += calculateDistance(last[1], last[0], first[1], first[0]);
+ }
+
+ return perimeter;
+}
+
+/**
+ * 计算多边形中心点
+ */
+export function calculatePolygonCentroid(polygon: number[][]): { lat: number; lng: number } {
+ let sumLat = 0;
+ let sumLng = 0;
+
+ for (const point of polygon) {
+ sumLat += point[1];
+ sumLng += point[0];
+ }
+
+ return {
+ lat: sumLat / polygon.length,
+ lng: sumLng / polygon.length
+ };
+}
+
+/**
+ * 计算多边形边界框
+ */
+export function calculatePolygonBounds(polygon: number[][]): {
+ north: number;
+ south: number;
+ east: number;
+ west: number;
+} {
+ let minLat = Infinity, maxLat = -Infinity;
+ let minLng = Infinity, maxLng = -Infinity;
+
+ for (const point of polygon) {
+ minLat = Math.min(minLat, point[1]);
+ maxLat = Math.max(maxLat, point[1]);
+ minLng = Math.min(minLng, point[0]);
+ maxLng = Math.max(maxLng, point[0]);
+ }
+
+ return {
+ north: maxLat,
+ south: minLat,
+ east: maxLng,
+ west: minLng
+ };
+}
+
+/**
+ * 空间查询:点在多边形内
+ */
+export function queryPointInPolygon(
+ point: { lat: number; lng: number },
+ fields: SpatialField[]
+): SpatialField[] {
+ return fields.filter(field => {
+ const polygon = field.geometry.coordinates[0];
+ return isPointInPolygon(point, polygon);
+ });
+}
+
+/**
+ * 空间查询:多边形相交
+ */
+export function queryPolygonIntersect(
+ queryPolygon: number[][],
+ fields: SpatialField[]
+): SpatialField[] {
+ return fields.filter(field => {
+ const polygon = field.geometry.coordinates[0];
+ return doPolygonsIntersect(queryPolygon, polygon);
+ });
+}
+
+/**
+ * 空间查询:多边形相邻
+ */
+export function queryPolygonAdjacent(
+ queryPolygon: number[][],
+ fields: SpatialField[]
+): SpatialField[] {
+ return fields.filter(field => {
+ const polygon = field.geometry.coordinates[0];
+ return arePolygonsAdjacent(queryPolygon, polygon);
+ });
+}
+
+/**
+ * 空间查询:缓冲区查询
+ */
+export function queryBuffer(
+ center: { lat: number; lng: number },
+ radius: number,
+ fields: SpatialField[]
+): SpatialField[] {
+ const bufferPolygon = createBuffer(center, radius);
+
+ return fields.filter(field => {
+ // 检查地块中心点是否在缓冲区内
+ return isPointInPolygon(field.centroid, bufferPolygon);
+ });
+}
+
+/**
+ * 生成GeoJSON格式数据
+ */
+export function generateGeoJSON(fields: SpatialField[]): string {
+ const features = fields.map(field => ({
+ type: 'Feature',
+ properties: {
+ id: field.id,
+ code: field.code,
+ name: field.name,
+ area: field.area,
+ perimeter: field.perimeter,
+ soilType: field.soilType,
+ plantingMode: field.plantingMode,
+ status: field.status,
+ owner: field.owner,
+ createdAt: field.createdAt
+ },
+ geometry: field.geometry
+ }));
+
+ return JSON.stringify({
+ type: 'FeatureCollection',
+ features
+ }, null, 2);
+}
+
+/**
+ * 生成KML格式数据
+ */
+export function generateKML(fields: SpatialField[]): string {
+ let kml = `
+
+
+ Spatial Query Results
+`;
+
+ for (const field of fields) {
+ const coordinates = field.geometry.coordinates[0]
+ .map(coord => `${coord[0]},${coord[1]},0`)
+ .join(' ');
+
+ kml += `
+ ${field.name}
+
+ 编号: ${field.code}
+ 面积: ${field.area.toFixed(2)} 亩
+ 土壤类型: ${field.soilType}
+ 种植模式: ${field.plantingMode}
+ 负责人: ${field.owner}
+ ]]>
+
+
+
+
+ ${coordinates}
+
+
+
+
+`;
+ }
+
+ kml += `
+`;
+
+ return kml;
+}
+
+/**
+ * 生成CSV格式数据
+ */
+export function generateCSV(fields: SpatialField[]): string {
+ const headers = [
+ 'ID', '编号', '名称', '面积(亩)', '周长(米)', '中心纬度', '中心经度',
+ '土壤类型', '种植模式', '状态', '负责人', '创建时间'
+ ];
+
+ const rows = fields.map(field => [
+ field.id,
+ field.code,
+ field.name,
+ field.area.toFixed(2),
+ field.perimeter.toFixed(0),
+ field.centroid.lat.toFixed(6),
+ field.centroid.lng.toFixed(6),
+ field.soilType,
+ field.plantingMode,
+ field.status,
+ field.owner,
+ field.createdAt
+ ]);
+
+ return [headers, ...rows].map(row => row.join(',')).join('\n');
+}
+
+/**
+ * 生成SQL查询示例
+ */
+export function generateSQLExample(
+ queryType: string,
+ geometry: any,
+ bufferDistance?: number
+): string {
+ switch (queryType) {
+ case 'point-in-polygon':
+ return `-- 点在多边形内查询
+SELECT * FROM fields
+WHERE ST_Contains(
+ ST_GeomFromText('POLYGON((${geometry.map((p: any) => `${p[0]} ${p[1]}`).join(', ')}))', 4326),
+ ST_GeomFromText('POINT(${geometry.lng} ${geometry.lat})', 4326)
+);`;
+
+ case 'polygon-intersect':
+ return `-- 多边形相交查询
+SELECT * FROM fields
+WHERE ST_Intersects(
+ geometry,
+ ST_GeomFromText('POLYGON((${geometry.map((p: any) => `${p[0]} ${p[1]}`).join(', ')}))', 4326)
+);`;
+
+ case 'polygon-adjacent':
+ return `-- 多边形相邻查询
+SELECT * FROM fields
+WHERE ST_Touches(
+ geometry,
+ ST_GeomFromText('POLYGON((${geometry.map((p: any) => `${p[0]} ${p[1]}`).join(', ')}))', 4326)
+);`;
+
+ case 'buffer':
+ return `-- 缓冲区查询
+SELECT * FROM fields
+WHERE ST_DWithin(
+ geometry,
+ ST_GeomFromText('POINT(${geometry.lng} ${geometry.lat})', 4326),
+ ${bufferDistance}
+);`;
+
+ default:
+ return '-- 请选择查询类型';
+ }
+}
\ No newline at end of file
diff --git a/crop-x/src/app/(app)/land-information/map/spatial-query/page.tsx b/crop-x/src/app/(app)/land-information/map/spatial-query/page.tsx
index 159a3cc..f664f73 100644
--- a/crop-x/src/app/(app)/land-information/map/spatial-query/page.tsx
+++ b/crop-x/src/app/(app)/land-information/map/spatial-query/page.tsx
@@ -1,17 +1,1777 @@
-'use client';
-
+/**
+ * 空间数据管理组件
+ * 集成PostGIS风格的空间查询、几何计算和数据导出功能
+ */
+'use client'
+import { useState, useEffect } from 'react';
+import { loadAMapScript } from '@/lib/mapLoader';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import {
+ MapPin,
+ Search,
+ Download,
+ Calculator,
+ Copy,
+ FileJson,
+ Map as MapIcon,
+ Target,
+ GitMerge,
+ Circle,
+ CheckCircle2,
+ AlertCircle,
+ FileText,
+ Database,
+ Ruler,
+ Plus,
+ Trash2,
+ MousePointer2,
+ Edit3
+} from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ SpatialQuery,
+ GeometryCalculator,
+ DataExporter,
+ Field,
+ Point,
+ Polygon,
+ SpatialQueryResult
+} from '@/lib/spatialDataService';
+import { MapPointPicker } from '@/components/field/MapPointPicker';
export default function SpatialQueryPage() {
+ const [queryType, setQueryType] = useState<'point-in-polygon' | 'polygon-intersect' | 'polygon-adjacent' | 'buffer'>('point-in-polygon');
+ const [pointLat, setPointLat] = useState('39.9042');
+ const [pointLng, setPointLng] = useState('116.4074');
+ const [selectedFieldId, setSelectedFieldId] = useState('');
+ const [bufferDistance, setBufferDistance] = useState('500');
+ const [queryResult, setQueryResult] = useState(null);
+ const [isQuerying, setIsQuerying] = useState(false);
+
+ // 几何计算对话框状态
+ const [showGeometryDialog, setShowGeometryDialog] = useState(false);
+ const [geomCalcType, setGeomCalcType] = useState<'area' | 'perimeter' | 'centroid' | 'distance' | 'bbox'>('area');
+ const [geomPoints, setGeomPoints] = useState([
+ { lat: 39.9040, lng: 116.4070 },
+ { lat: 39.9080, lng: 116.4070 },
+ { lat: 39.9080, lng: 116.4120 },
+ { lat: 39.9040, lng: 116.4120 },
+ ]);
+ const [distPoint1, setDistPoint1] = useState({ lat: 39.9042, lng: 116.4074 });
+ const [distPoint2, setDistPoint2] = useState({ lat: 39.9150, lng: 116.4150 });
+ const [geomResult, setGeomResult] = useState(null);
+
+ // 地图选点模式
+ const [mapPickMode, setMapPickMode] = useState<'polygon' | 'distance-p1' | 'distance-p2' | null>(null);
+ const [editingPointIndex, setEditingPointIndex] = useState(null);
+
+ // 地图加载状态
+ const [isMapLoaded, setIsMapLoaded] = useState(false);
+
+ // 加载高德地图SDK
+ useEffect(() => {
+ if (typeof window.AMap === 'undefined') {
+ loadAMapScript()
+ .then(() => {
+ setIsMapLoaded(true);
+ console.log('✅ 高德地图SDK加载成功');
+ })
+ .catch((error) => {
+ console.error('❌ 高德地图SDK加载失败:', error);
+ toast.error('地图加载失败,部分功能可能不可用');
+ });
+ } else {
+ setIsMapLoaded(true);
+ }
+ }, []);
+
+ // 模拟地块数据(实际应从后端获取)
+ const mockFields: Field[] = [
+ {
+ id: 'field-1',
+ name: '东区1号地',
+ code: 'DB001',
+ geometry: {
+ points: [
+ { lat: 39.9040, lng: 116.4070 },
+ { lat: 39.9080, lng: 116.4070 },
+ { lat: 39.9080, lng: 116.4120 },
+ { lat: 39.9040, lng: 116.4120 },
+ ],
+ },
+ properties: { crop: '小麦', owner: '张三' },
+ },
+ {
+ id: 'field-2',
+ name: '西区2号地',
+ code: 'DB002',
+ geometry: {
+ points: [
+ { lat: 39.9045, lng: 116.4120 },
+ { lat: 39.9085, lng: 116.4120 },
+ { lat: 39.9085, lng: 116.4170 },
+ { lat: 39.9045, lng: 116.4170 },
+ ],
+ },
+ properties: { crop: '玉米', owner: '李四' },
+ },
+ {
+ id: 'field-3',
+ name: '南区3号地',
+ code: 'DB003',
+ geometry: {
+ points: [
+ { lat: 39.9000, lng: 116.4070 },
+ { lat: 39.9040, lng: 116.4070 },
+ { lat: 39.9040, lng: 116.4120 },
+ { lat: 39.9000, lng: 116.4120 },
+ ],
+ },
+ properties: { crop: '水稻', owner: '王五' },
+ },
+ {
+ id: 'field-4',
+ name: '北区4号地',
+ code: 'DB004',
+ geometry: {
+ points: [
+ { lat: 39.9080, lng: 116.4070 },
+ { lat: 39.9120, lng: 116.4070 },
+ { lat: 39.9120, lng: 116.4120 },
+ { lat: 39.9080, lng: 116.4120 },
+ ],
+ },
+ properties: { crop: '大豆', owner: '赵六' },
+ },
+ ];
+
+ // 点面查询
+ const handlePointInPolygonQuery = () => {
+ setIsQuerying(true);
+
+ setTimeout(() => {
+ const point: Point = {
+ lat: parseFloat(pointLat),
+ lng: parseFloat(pointLng),
+ };
+
+ const result = SpatialQuery.pointInPolygon(point, mockFields);
+
+ setQueryResult({
+ type: 'point-in-polygon',
+ point,
+ ...result.data,
+ executionTime: result.executionTime,
+ timestamp: result.timestamp,
+ });
+
+ setIsQuerying(false);
+
+ if (result.data.matched) {
+ toast.success(`查询完成!该点位于 ${result.data.fields[0].field.name} 内 (耗时: ${result.executionTime.toFixed(2)}ms)`);
+ } else {
+ toast.info('查询完成!该点不在任何地块内');
+ }
+ }, 100);
+ };
+
+ // 面面相交查询
+ const handlePolygonIntersectQuery = () => {
+ setIsQuerying(true);
+
+ setTimeout(() => {
+ const sourceField = mockFields.find((f) => f.id === selectedFieldId);
+ if (!sourceField) {
+ toast.error('请选择地块');
+ setIsQuerying(false);
+ return;
+ }
+
+ const result = SpatialQuery.polygonIntersect(
+ sourceField,
+ mockFields.filter((f) => f.id !== selectedFieldId)
+ );
+
+ setQueryResult({
+ type: 'polygon-intersect',
+ sourceField,
+ ...result.data,
+ executionTime: result.executionTime,
+ timestamp: result.timestamp,
+ });
+
+ setIsQuerying(false);
+ toast.success(`查询完成!发现 ${result.data.intersections.length} 个相交地块 (耗时: ${result.executionTime.toFixed(2)}ms)`);
+ }, 100);
+ };
+
+ // 相邻地块查询
+ const handleAdjacentQuery = () => {
+ setIsQuerying(true);
+
+ setTimeout(() => {
+ const sourceField = mockFields.find((f) => f.id === selectedFieldId);
+ if (!sourceField) {
+ toast.error('请选择地块');
+ setIsQuerying(false);
+ return;
+ }
+
+ const result = SpatialQuery.adjacentPolygons(
+ sourceField,
+ mockFields.filter((f) => f.id !== selectedFieldId)
+ );
+
+ setQueryResult({
+ type: 'polygon-adjacent',
+ sourceField,
+ ...result.data,
+ executionTime: result.executionTime,
+ timestamp: result.timestamp,
+ });
+
+ setIsQuerying(false);
+ toast.success(`查询完成!发现 ${result.data.adjacentFields.length} 个相邻地块 (耗时: ${result.executionTime.toFixed(2)}ms)`);
+ }, 100);
+ };
+
+ // 缓冲区分析
+ const handleBufferQuery = () => {
+ setIsQuerying(true);
+
+ setTimeout(() => {
+ const sourceField = mockFields.find((f) => f.id === selectedFieldId);
+ if (!sourceField) {
+ toast.error('请选择地块');
+ setIsQuerying(false);
+ return;
+ }
+
+ const result = SpatialQuery.bufferAnalysis(
+ sourceField,
+ parseFloat(bufferDistance),
+ mockFields.filter((f) => f.id !== selectedFieldId)
+ );
+
+ setQueryResult({
+ type: 'buffer',
+ sourceField,
+ bufferDistance: parseFloat(bufferDistance),
+ ...result.data,
+ executionTime: result.executionTime,
+ timestamp: result.timestamp,
+ });
+
+ setIsQuerying(false);
+ toast.success(`查询完成!在${bufferDistance}米范围内发现 ${result.data.fieldsInBuffer.length} 个地块 (耗时: ${result.executionTime.toFixed(2)}ms)`);
+ }, 100);
+ };
+
+ const handleQuery = () => {
+ switch (queryType) {
+ case 'point-in-polygon':
+ handlePointInPolygonQuery();
+ break;
+ case 'polygon-intersect':
+ handlePolygonIntersectQuery();
+ break;
+ case 'polygon-adjacent':
+ handleAdjacentQuery();
+ break;
+ case 'buffer':
+ handleBufferQuery();
+ break;
+ }
+ };
+
+ // 几何计算 - 添加/删除/更新点
+ const handleAddGeomPoint = () => {
+ setGeomPoints([...geomPoints, { lat: 0, lng: 0 }]);
+ };
+
+ const handleRemoveGeomPoint = (index: number) => {
+ if (geomPoints.length <= 3) {
+ toast.error('至少需要3个点才能形成多边形');
+ return;
+ }
+ setGeomPoints(geomPoints.filter((_, i) => i !== index));
+ };
+
+ const handleUpdateGeomPoint = (index: number, field: 'lat' | 'lng', value: string) => {
+ const newPoints = [...geomPoints];
+ newPoints[index][field] = parseFloat(value) || 0;
+ setGeomPoints(newPoints);
+ };
+
+ // 几何计算 - 计算面积
+ const handleCalculateArea = () => {
+ try {
+ const polygon: Polygon = { points: geomPoints };
+ const areaM2 = GeometryCalculator.calculateArea(polygon);
+ const areaMu = areaM2 / 666.67;
+ const areaHa = areaM2 / 10000;
+
+ setGeomResult({
+ type: 'area',
+ areaM2: areaM2.toFixed(2),
+ areaMu: areaMu.toFixed(2),
+ areaHa: areaHa.toFixed(4),
+ timestamp: Date.now()
+ });
+
+ toast.success(`计算完成!面积:${areaMu.toFixed(2)} 亩`);
+ } catch (error) {
+ toast.error('计算失败,请检查坐标点');
+ }
+ };
+
+ // 几何计算 - 计算周长
+ const handleCalculatePerimeter = () => {
+ try {
+ const polygon: Polygon = { points: geomPoints };
+ const perimeter = GeometryCalculator.calculatePerimeter(polygon);
+ const perimeterKm = perimeter / 1000;
+
+ setGeomResult({
+ type: 'perimeter',
+ perimeter: perimeter.toFixed(2),
+ perimeterKm: perimeterKm.toFixed(3),
+ timestamp: Date.now()
+ });
+
+ toast.success(`计算完成!周长:${perimeter.toFixed(2)} 米`);
+ } catch (error) {
+ toast.error('计算失败,请检查坐标点');
+ }
+ };
+
+ // 几何计算 - 计算中心点
+ const handleCalculateCentroid = () => {
+ try {
+ const polygon: Polygon = { points: geomPoints };
+ const centroid = GeometryCalculator.calculateCentroid(polygon);
+
+ setGeomResult({
+ type: 'centroid',
+ centroid,
+ timestamp: Date.now()
+ });
+
+ toast.success(`计算完成!中心点:(${centroid.lat.toFixed(6)}, ${centroid.lng.toFixed(6)})`);
+ } catch (error) {
+ toast.error('计算失败,请检查坐标点');
+ }
+ };
+
+ // 几何计算 - 计算距离
+ const handleCalculateDistance = () => {
+ try {
+ const distance = GeometryCalculator.haversineDistance(distPoint1, distPoint2);
+ const distanceKm = distance / 1000;
+
+ setGeomResult({
+ type: 'distance',
+ distance: distance.toFixed(2),
+ distanceKm: distanceKm.toFixed(3),
+ point1: distPoint1,
+ point2: distPoint2,
+ timestamp: Date.now()
+ });
+
+ toast.success(`计算完成!距离:${distance.toFixed(2)} 米`);
+ } catch (error) {
+ toast.error('计算失败,请检查坐标点');
+ }
+ };
+
+ // 几何计算 - 计算包围盒
+ const handleCalculateBBox = () => {
+ try {
+ const polygon: Polygon = { points: geomPoints };
+ const bbox = GeometryCalculator.calculateBoundingBox(polygon);
+
+ setGeomResult({
+ type: 'bbox',
+ bbox,
+ timestamp: Date.now()
+ });
+
+ toast.success('计算完成!');
+ } catch (error) {
+ toast.error('计算失败,请检查坐标点');
+ }
+ };
+
+ // 打开几何计算对话框
+ const handleGeometryCalculation = () => {
+ setShowGeometryDialog(true);
+ };
+
+ // 几何计算演示(已废弃,改用对话框)
+ const handleGeometryCalculationOld = () => {
+ const field = mockFields[0];
+
+ const area = GeometryCalculator.calculateArea(field.geometry);
+ const perimeter = GeometryCalculator.calculatePerimeter(field.geometry);
+ const centroid = GeometryCalculator.calculateCentroid(field.geometry);
+ const bbox = GeometryCalculator.calculateBoundingBox(field.geometry);
+
+ toast.success(
+ `${field.name}:\n` +
+ `面积: ${area.toFixed(2)} 亩\n` +
+ `周长: ${perimeter.toFixed(0)} 米\n` +
+ `中心点: (${centroid.lat.toFixed(6)}, ${centroid.lng.toFixed(6)})\n` +
+ `包围盒: [${bbox.minLat.toFixed(4)}, ${bbox.minLng.toFixed(4)}, ${bbox.maxLat.toFixed(4)}, ${bbox.maxLng.toFixed(4)}]`,
+ { duration: 5000 }
+ );
+ };
+
+ // 导出为GeoJSON
+ const exportToGeoJSON = () => {
+ try {
+ const geoJSON = DataExporter.exportToGeoJSON(mockFields);
+ DataExporter.downloadFile(
+ geoJSON,
+ `fields-${Date.now()}.geojson`,
+ 'application/json'
+ );
+ toast.success('成功导出为GeoJSON格式');
+ } catch (error) {
+ toast.error('导出失败');
+ }
+ };
+
+ // 导出为KML
+ const exportToKML = () => {
+ try {
+ const kml = DataExporter.exportToKML(mockFields);
+ DataExporter.downloadFile(
+ kml,
+ `fields-${Date.now()}.kml`,
+ 'application/vnd.google-earth.kml+xml'
+ );
+ toast.success('成功导出为KML格式');
+ } catch (error) {
+ toast.error('导出失败');
+ }
+ };
+
+ // 导出为CSV
+ const exportToCSV = () => {
+ try {
+ const csv = DataExporter.exportToCSV(mockFields);
+ DataExporter.downloadFile(
+ csv,
+ `fields-${Date.now()}.csv`,
+ 'text/csv'
+ );
+ toast.success('成功导出为CSV格式');
+ } catch (error) {
+ toast.error('导出失败');
+ }
+ };
+
return (
-
- 地块空间查询
-
-
- 页面路径: /land-information/map/spatial-query
+
+
+
空间数据管理
+
+ PostGIS风格的空间查询、几何计算与数据导出
+
+
+
+
+
+
+
+
+ {/* 数据库信息卡片 */}
+
+
+
+
+
空间数据库 (PostGIS风格)
+
+
+
坐标系统
+
WGS-84 (EPSG:4326)
+
+
+
几何类型
+
Polygon, Point
+
+
+
+
精度算法
+
球面几何 (Haversine)
+
+
+
+
+
+
+
setQueryType(v as any)}>
+
+
+
+ 点面查询
+
+
+
+ 面面相交
+
+
+
+ 相邻查询
+
+
+
+ 缓冲区分析
+
+
+
+ {/* 点面查询 */}
+
+
+
+
+
+
+
点在面内查询 (ST_Contains)
+
+
+ 输入坐标点,查询该点落在哪个地块内 - 使用射线法(Ray Casting Algorithm)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SQL示例
+
+{`SELECT * FROM fields
+WHERE ST_Contains(
+ geometry,
+ ST_GeomFromText('POINT(${pointLng} ${pointLat})', 4326)
+);`}
+
+
+
+ 查询统计
+
+
总地块数: {mockFields.length}
+
总面积: {mockFields.reduce((sum, f) => sum + GeometryCalculator.calculateArea(f.geometry), 0).toFixed(2)} 亩
+
+
+
+
+
+ {/* 查询结果 */}
+ {queryResult && queryResult.type === 'point-in-polygon' && (
+
+
+
+
查询结果
+
+ 耗时: {queryResult.executionTime.toFixed(2)}ms
+
+
+
+ {queryResult.matched ? (
+
+
+
+ ✓ 点 ({queryResult.point.lat.toFixed(6)}, {queryResult.point.lng.toFixed(6)})
+ 位于 {queryResult.fields[0].field.name} 内
+
+
+
+
+
+ 地块信息
+
+
名称: {queryResult.fields[0].field.name}
+
编号: {queryResult.fields[0].field.code}
+
作物: {queryResult.fields[0].field.properties?.crop}
+
负责人: {queryResult.fields[0].field.properties?.owner}
+
+
+
+
+ 几何信息
+
+
+ 到边界距离:
+
+ {queryResult.fields[0].distanceToBorder.toFixed(2)} 米
+
+
+
+
+
+
+ ) : (
+
+
+ ✗ 点 ({queryResult.point.lat.toFixed(6)}, {queryResult.point.lng.toFixed(6)})
+ 不在任何地块内
+
+
+ )}
+
+ )}
+
+
+ {/* 面面相交查询 */}
+
+
+
+
+
+
+
多边形相交查询 (ST_Intersects)
+
+
+ 选择源地块,查询与其相交的所有地块
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SQL示例
+
+{`SELECT * FROM fields
+WHERE ST_Intersects(
+ geometry,
+ ST_GeomFromText('POLYGON((...))', 4326)
+) AND id <> 'source_field_id';`}
+
+
+
+ 地块列表
+
+ {mockFields.map((field) => (
+
setSelectedFieldId(field.id)}
+ >
+
{field.name}
+
{field.code}
+
+ ))}
+
+
+
+
+
+ {/* 查询结果 */}
+ {queryResult && queryResult.type === 'polygon-intersect' && (
+
+
+
+
相交结果
+
+ 发现 {queryResult.intersections.length} 个相交地块
+
+
+ 耗时: {queryResult.executionTime.toFixed(2)}ms
+
+
+
+
+ {queryResult.intersections.length > 0 ? (
+ queryResult.intersections.map((intersection, index) => (
+
+
+
{intersection.field.name}
+
+ {intersection.intersectRatio.toFixed(1)}% 相交
+
+
+
+
+
相交面积:
+
+ {intersection.intersectArea.toFixed(2)} 亩
+
+
+
+
相交比例:
+
+
+ {intersection.intersectRatio.toFixed(1)}%
+
+
+
+
+
地块作物:
+
+ {intersection.field.properties?.crop || '未知'}
+
+
+
+
+ ))
+ ) : (
+
+ 没有找到相交的地块
+
+ )}
+
+
+ )}
+
+
+ {/* 相邻查询 */}
+
+
+
+
+
+
+
相邻地块查询 (ST_Touches)
+
+
+ 选择源地块,查询与其相邻(共享边界)的地块
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SQL示例
+
+{`SELECT * FROM fields
+WHERE ST_Touches(
+ geometry,
+ ST_GeomFromText('POLYGON((...))', 4326)
+) AND id <> 'source_field_id';`}
+
+
+
+ 地块列表
+
+ {mockFields.map((field) => (
+
setSelectedFieldId(field.id)}
+ >
+
{field.name}
+
{field.code}
+
+ ))}
+
+
+
+
+
+ {/* 查询结果 */}
+ {queryResult && queryResult.type === 'polygon-adjacent' && (
+
+
+
+
相邻结果
+
+ 发现 {queryResult.adjacentFields.length} 个相邻地块
+
+
+ 耗时: {queryResult.executionTime.toFixed(2)}ms
+
+
+
+
+ {queryResult.adjacentFields.length > 0 ? (
+ queryResult.adjacentFields.map((adjacent, index) => (
+
+
+
{adjacent.field.name}
+
+ 共享边界
+
+
+
+
+
共享边界长度:
+
+ {adjacent.sharedBorderLength.toFixed(2)} 米
+
+
+
+
共享边界点数:
+
+ {adjacent.sharedBorderPoints.length} 个
+
+
+
+
地块作物:
+
+ {adjacent.field.properties?.crop || '未知'}
+
+
+
+
+ ))
+ ) : (
+
+ 没有找到相邻的地块
+
+ )}
+
+
+ )}
+
+
+ {/* 缓冲区分析 */}
+
+
+
+
+
+
+
缓冲区分析 (ST_DWithin)
+
+
+ 选择源地块和缓冲距离,查询范围内的地块
+
+
+
+
+
+
+
+
+
+
+ setBufferDistance(e.target.value)}
+ placeholder="500"
+ />
+
+
+
+
+
+
+
+
+
+ SQL示例
+
+{`SELECT * FROM fields
+WHERE ST_DWithin(
+ geometry,
+ ST_GeomFromText('POINT(${mockFields.find(f => f.id === selectedFieldId)?.geometry.points[0].lng} ${mockFields.find(f => f.id === selectedFieldId)?.geometry.points[0].lat})', 4326),
+ ${bufferDistance}
+);`}
+
+
+
+ 缓冲区预览
+
+ {selectedFieldId && (
+
+
源地块: {mockFields.find(f => f.id === selectedFieldId)?.name}
+
缓冲半径: {bufferDistance} 米
+
缓冲面积: {queryResult?.bufferArea ? queryResult.bufferArea.toFixed(2) : '0'} 亩
+
+ )}
+
+
+
+
+
+ {/* 查询结果 */}
+ {queryResult && queryResult.type === 'buffer' && (
+
+
+
+
缓冲区结果
+
+ 范围内发现 {queryResult.fieldsInBuffer.length} 个地块
+
+
+ 缓冲区: {queryResult.bufferDistance} 米
+
+
+ 耗时: {queryResult.executionTime.toFixed(2)}ms
+
+
+
+
+ {queryResult.fieldsInBuffer.length > 0 ? (
+ <>
+
+
+ 缓冲区几何已创建,面积为 {queryResult.bufferArea.toFixed(2)} 亩
+
+
+
+ {queryResult.fieldsInBuffer.map((fieldInBuffer, index) => (
+
+
+
{fieldInBuffer.field.name}
+
+ {fieldInBuffer.overlap ? '重叠' : '范围内'}
+
+
+
+
+
最短距离:
+
+ {fieldInBuffer.distance.toFixed(2)} 米
+
+
+
+
是否重叠:
+
+ {fieldInBuffer.overlap ? '是' : '否'}
+
+
+
+
地块作物:
+
+ {fieldInBuffer.field.properties?.crop || '未知'}
+
+
+
+
+ ))}
+ >
+ ) : (
+
+ 在 {bufferDistance} 米范围内没有发现地块
+
+ )}
+
+
+ )}
+
+
+
+ {/* 几何计算对话框 */}
+
+
+ {/* 几何计算工具说明 */}
+
+ 几何计算工具 (Geometry Functions)
+
+
+
+
+ ST_Area
+
+
+ 球面几何精确面积计算(L'Huilier定理)
+
+
+
+
+
+ ST_Perimeter
+
+
+ Haversine公式计算周长
+
+
+
+
+
+ ST_Centroid
+
+
+ 计算几何中心坐标
+
+
+
+
+
+ ST_AsGeoJSON
+
+
+ 导出为GeoJSON格式
+
+
+
+
+
+ {/* 使用说明 */}
+
+
+
+
+
空间数据服务API特性:
+
+ - • PostGIS兼容:提供ST_Contains, ST_Intersects, ST_Touches等函数
+ - • 精确计算:考虑地球曲率,使用WGS-84椭球参数
+ - • 高性能:R-Tree空间索引,包围盒预筛选
+ - • 标准格式:支持GeoJSON、KML、WKT、CSV导出
+ - • 球面几何:Haversine距离公式,L'Huilier面积算法
+ - • 拓扑关系:点面、面面、相邻、缓冲区等空间关系查询
+
+
+
);
diff --git a/crop-x/src/components/field/MapPointPicker.tsx b/crop-x/src/components/field/MapPointPicker.tsx
new file mode 100644
index 0000000..bf679d4
--- /dev/null
+++ b/crop-x/src/components/field/MapPointPicker.tsx
@@ -0,0 +1,238 @@
+/**
+ * 地图选点组件
+ * 用于在地图上选择坐标点
+ */
+
+import { useEffect, useRef, useState } from 'react';
+import { Card } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Point } from '../../lib/spatialDataService';
+import { MapPin, X, Check } from 'lucide-react';
+
+interface MapPointPickerProps {
+ points: Point[];
+ mode: 'polygon' | 'single';
+ onPointsChange: (points: Point[]) => void;
+ onClose?: () => void;
+ height?: string;
+ title?: string;
+}
+
+export function MapPointPicker({
+ points,
+ mode,
+ onPointsChange,
+ onClose,
+ height = '500px',
+ title = '在地图上选择坐标点'
+}: MapPointPickerProps) {
+ const mapRef = useRef(null);
+ const [map, setMap] = useState(null);
+ const [markers, setMarkers] = useState([]);
+ const [polygon, setPolygon] = useState(null);
+
+ // 初始化地图
+ useEffect(() => {
+ if (!mapRef.current || map) return;
+
+ // 检查高德地图是否已加载
+ if (typeof window.AMap === 'undefined') {
+ console.log('💡 使用演示地图模式');
+ return;
+ }
+
+ // 计算中心点
+ const centerLat = points && points.length > 0
+ ? points.reduce((sum, p) => sum + p.lat, 0) / points.length
+ : 39.9042;
+ const centerLng = points && points.length > 0
+ ? points.reduce((sum, p) => sum + p.lng, 0) / points.length
+ : 116.4074;
+
+ // 创建地图实例
+ const mapInstance = new window.AMap.Map(mapRef.current, {
+ zoom: 14,
+ center: [centerLng, centerLat],
+ mapStyle: 'amap://styles/normal',
+ viewMode: '2D'
+ });
+
+ setMap(mapInstance);
+
+ return () => {
+ if (mapInstance) {
+ mapInstance.destroy();
+ }
+ };
+ }, []);
+
+ // 绘制标记和多边形
+ useEffect(() => {
+ if (!map || !points) return;
+
+ // 清除旧标记
+ markers.forEach(marker => marker.setMap(null));
+ if (polygon) {
+ polygon.setMap(null);
+ }
+
+ // 添加新标记
+ const newMarkers = points.map((point, index) => {
+ const marker = new window.AMap.Marker({
+ position: [point.lng, point.lat],
+ map: map,
+ title: `点 ${index + 1}`,
+ label: {
+ content: `${index + 1}`,
+ direction: 'top'
+ }
+ });
+
+ // 点击标记删除(仅多边形模式且点数>3)
+ if (mode === 'polygon' && points.length > 3) {
+ marker.on('click', () => {
+ const newPoints = points.filter((_, i) => i !== index);
+ onPointsChange(newPoints);
+ });
+ }
+
+ return marker;
+ });
+
+ setMarkers(newMarkers);
+
+ // 绘制多边形(仅多边形模式)
+ if (mode === 'polygon' && points.length >= 3) {
+ const path = points.map(p => [p.lng, p.lat]);
+ const poly = new window.AMap.Polygon({
+ path: path,
+ strokeColor: '#22c55e',
+ strokeWeight: 2,
+ strokeOpacity: 0.8,
+ fillColor: '#22c55e',
+ fillOpacity: 0.2,
+ map: map
+ });
+ setPolygon(poly);
+ }
+
+ // 自适应显示
+ if (points && points.length > 0) {
+ map.setFitView();
+ }
+ }, [map, points]);
+
+ // 地图点击事件
+ useEffect(() => {
+ if (!map) return;
+
+ const clickHandler = (e: any) => {
+ const lng = e.lnglat.getLng();
+ const lat = e.lnglat.getLat();
+
+ if (mode === 'single') {
+ // 单点模式:替换唯一的点
+ onPointsChange([{ lat, lng }]);
+ } else {
+ // 多边形模式:添加新点
+ onPointsChange([...(points || []), { lat, lng }]);
+ }
+ };
+
+ map.on('click', clickHandler);
+
+ return () => {
+ map.off('click', clickHandler);
+ };
+ }, [map, points, mode]);
+
+ return (
+
+ {/* 工具栏 */}
+
+
+
+ {title}
+
+ {mode === 'polygon' ? `${points.length} 个点` : '单点选择'}
+
+
+
+ {mode === 'polygon' && points.length > 0 && (
+
+ )}
+ {onClose && (
+
+ )}
+
+
+
+ {/* 提示信息 */}
+
+
+ {mode === 'polygon'
+ ? '💡 点击地图添加坐标点,点击标记删除该点(至少保留3个点)'
+ : '💡 点击地图选择坐标点位置'}
+
+
+
+ {/* 地图容器 */}
+
+ {/* 地图加载提示 */}
+ {!map && (
+
+ )}
+
+
+ {/* 坐标列表 */}
+ {points && points.length > 0 && (
+
+
+ 选中的坐标点:
+
+
+ {points.map((point, index) => (
+
+
+ 点{index + 1}: {point.lat.toFixed(6)}, {point.lng.toFixed(6)}
+
+ {mode === 'polygon' && points.length > 3 && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/crop-x/src/components/layouts/ThemeToggle.tsx b/crop-x/src/components/layouts/ThemeToggle.tsx
index 95adbe5..5c91c93 100644
--- a/crop-x/src/components/layouts/ThemeToggle.tsx
+++ b/crop-x/src/components/layouts/ThemeToggle.tsx
@@ -25,9 +25,9 @@ export function ThemeToggle() {
variant="ghost"
size="icon"
disabled
- className="transition-colors"
+ className="transition-colors h-10 w-10"
>
-
+
);
}
@@ -38,12 +38,12 @@ export function ThemeToggle() {
size="icon"
onClick={toggleTheme}
title={theme === 'light' ? '切换到深色模式' : '切换到浅色模式'}
- className="transition-colors"
+ className="transition-colors h-10 w-10"
>
{theme === 'light' ? (
-
+
) : (
-
+
)}
);
diff --git a/crop-x/src/components/layouts/components/MessageBell.tsx b/crop-x/src/components/layouts/components/MessageBell.tsx
index b3e2cdc..2b2321f 100644
--- a/crop-x/src/components/layouts/components/MessageBell.tsx
+++ b/crop-x/src/components/layouts/components/MessageBell.tsx
@@ -134,7 +134,8 @@ export function MessageBell({ onMessageClick }: MessageBellProps) {