生产管理系统 - 全领域数据感知中心开发

This commit is contained in:
2025-10-30 20:20:24 +08:00
parent 5d5a24ac89
commit 8b7e86b8bf
21 changed files with 11093 additions and 1329 deletions

View File

@@ -1,5 +1,41 @@
# 开发项目规范 # 开发项目规范
## 通用开发规约
### 1. 文件头部注释规范filekorolheader
**规范要求:**
所有页面文件page.tsx必须在最上方添加filekorolheader注释说明文件对应的页面功能、路径和用途。
**格式标准:**
```tsx
/**
* filekorolheader: [页面名称] - [功能描述]
* 功能:[主要功能列表]
* 路径:[页面路由路径]
* 规范:[遵循的特殊规范说明]
*/
```
**示例:**
```tsx
/**
* filekorolheader: 物联设备数据接入页面 - IoT设备数据管理中心
* 功能:设备列表管理、实时数据监控、数据对比分析、报告生成
* 路径:/ai-crop-model/data-sense-center/iot
* 规范遵循crop-x/docs/开发项目规范.md使用useReducer状态管理shadcn语义化样式
*/
```
**实施要点:**
- 必须放在文件最顶部,在'use client'之前
- 页面名称要准确反映业务功能
- 功能描述要简明扼要,列出核心功能
- 路径必须是完整的路由路径
- 如有特殊规范遵循,需要在规范字段说明
---
## pathland-information/archive/statisticsname统计分析页面开发经验 ## pathland-information/archive/statisticsname统计分析页面开发经验
### 总体开发经验总结 ### 总体开发经验总结

5
crop-x/env/.env.dev vendored
View File

@@ -5,7 +5,10 @@ NODE_ENV=development
FRONTEND_BASE_URL=https://cavin-smart-crop-ui-app.dev.maimaiag.com FRONTEND_BASE_URL=https://cavin-smart-crop-ui-app.dev.maimaiag.com
# 后端 API 地址 # 后端 API 地址
BACKEND_BASE_URL=http://pengcode.tech:8080 BACKEND_BASE_URL=https://gitea-admin-hm-smart-agri-app.dev.maimaiag.com/
# OpenAPI 生成配置
API_BASE_URL=https://gitea-admin-hm-smart-agri-app.dev.maimaiag.com
# API 版本 # API 版本
API_VERSION=v1 API_VERSION=v1

View File

@@ -1,18 +0,0 @@
'use client';
import { Card } from '@/components/ui/card';
export default function ExternalPage() {
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> /ai-crop-model/data-center/external
</p>
</div>
</Card>
</div>
);
}

View File

@@ -1,18 +0,0 @@
'use client';
import { Card } from '@/components/ui/card';
export default function IotPage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold">IoT数据</h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /ai-crop-model/data-center/iot
</p>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,238 @@
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { ExternalDataState, ExternalDataAction } from './externalDataReducer';
import { DataSourceForm, DataSourceType, AccessMethod, accessMethods } from '../types';
import { Database, Plus, Upload, Code, Wifi } from 'lucide-react';
import { toast } from 'sonner';
interface AddDataSourceDialogProps {
state: ExternalDataState;
dispatch: React.Dispatch<ExternalDataAction>;
}
export function AddDataSourceDialog({ state, dispatch }: AddDataSourceDialogProps) {
const [formData, setFormData] = useState<DataSourceForm>({
name: '',
type: '气象数据',
provider: '',
accessMethod: 'API对接',
apiEndpoint: '',
updateFrequency: '',
description: '',
});
const resetForm = () => {
setFormData({
name: '',
type: '气象数据',
provider: '',
accessMethod: 'API对接',
apiEndpoint: '',
updateFrequency: '',
description: '',
});
};
const handleSubmit = () => {
if (!formData.name || !formData.provider || !formData.updateFrequency) {
toast.error('请填写必要字段');
return;
}
const newDataSource = {
id: `ext-${Date.now()}`,
...formData,
lastUpdateTime: new Date().toLocaleString('zh-CN'),
dataPoints: 0,
status: '待配置' as const,
dataFields: [],
};
dispatch({ type: 'ADD_DATA_SOURCE', payload: newDataSource });
dispatch({ type: 'SHOW_ADD_DIALOG', payload: false });
resetForm();
toast.success('数据源添加成功');
};
const handleClose = () => {
dispatch({ type: 'SHOW_ADD_DIALOG', payload: false });
resetForm();
};
const getAccessMethodIcon = (method: AccessMethod) => {
switch (method) {
case 'API对接':
return <Code className="w-4 h-4" />;
case 'FTP传输':
return <Upload className="w-4 h-4" />;
case 'WebSocket':
return <Wifi className="w-4 h-4" />;
case '手动上传':
return <Upload className="w-4 h-4" />;
default:
return <Database className="w-4 h-4" />;
}
};
return (
<Dialog open={state.showAddDialog} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 基本信息 */}
<Card className="p-4 bg-muted/20">
<h3 className="font-medium mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如国家气象局API"
/>
</div>
<div>
<Label htmlFor="provider"> *</Label>
<Input
id="provider"
value={formData.provider}
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
placeholder="例如:中国气象局"
/>
</div>
</div>
</Card>
{/* 数据配置 */}
<Card className="p-4 bg-muted/20">
<h3 className="font-medium mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="type"></Label>
<Select
value={formData.type}
onValueChange={(value: DataSourceType) =>
setFormData({ ...formData, type: value })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="气象数据"></SelectItem>
<SelectItem value="卫星遥感"></SelectItem>
<SelectItem value="土壤数据"></SelectItem>
<SelectItem value="作物生长"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="updateFrequency"> *</Label>
<Input
id="updateFrequency"
value={formData.updateFrequency}
onChange={(e) => setFormData({ ...formData, updateFrequency: e.target.value })}
placeholder="例如每小时、每5天"
/>
</div>
</div>
</Card>
{/* 接入方式 */}
<Card className="p-4 bg-muted/20">
<h3 className="font-medium mb-4"></h3>
<div className="space-y-4">
<div>
<Label></Label>
<div className="grid grid-cols-2 gap-3 mt-2">
{accessMethods.map((method) => (
<Button
key={method}
type="button"
variant={formData.accessMethod === method ? 'default' : 'outline'}
className="justify-start h-auto p-3"
onClick={() => setFormData({ ...formData, accessMethod: method })}
>
<div className="flex items-center gap-2">
{getAccessMethodIcon(method)}
<div className="text-left">
<div className="font-medium text-sm">{method}</div>
</div>
</div>
</Button>
))}
</div>
</div>
{formData.accessMethod === 'API对接' && (
<div>
<Label htmlFor="apiEndpoint">API端点</Label>
<Input
id="apiEndpoint"
value={formData.apiEndpoint}
onChange={(e) => setFormData({ ...formData, apiEndpoint: e.target.value })}
placeholder="https://api.example.com/v1/data"
/>
</div>
)}
</div>
</Card>
{/* 描述 */}
<Card className="p-4 bg-muted/20">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="描述数据源的用途、数据内容等信息..."
rows={3}
/>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handleSubmit}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ExternalDataSource, dataSourceTypes, dataSourceStatuses } from '../types';
import { ExternalDataAction } from './externalDataReducer';
import {
Database,
Eye,
Edit,
Trash2,
Cloud,
Code,
Upload,
Wifi,
CheckCircle,
XCircle,
AlertTriangle,
Clock,
Link,
} from 'lucide-react';
interface DataSourceCardProps {
dataSource: ExternalDataSource;
onView: (dataSource: ExternalDataSource) => void;
onEdit: (dataSource: ExternalDataSource) => void;
onDelete: (id: string) => void;
}
export function DataSourceCard({ dataSource, onView, onEdit, onDelete }: DataSourceCardProps) {
const getStatusIcon = (status: string) => {
switch (status) {
case '正常':
return <CheckCircle className="w-4 h-4 text-success" />;
case '异常':
return <XCircle className="w-4 h-4 text-destructive" />;
case '离线':
return <Clock className="w-4 h-4 text-muted-foreground" />;
case '待配置':
return <AlertTriangle className="w-4 h-4 text-warning" />;
default:
return <Clock className="w-4 h-4 text-muted-foreground" />;
}
};
const getAccessMethodIcon = (method: string) => {
switch (method) {
case 'API对接':
return <Link className="w-4 h-4" />;
case 'FTP传输':
return <Upload className="w-4 h-4" />;
case 'WebSocket':
return <Wifi className="w-4 h-4" />;
case '手动上传':
return <Upload className="w-4 h-4" />;
default:
return <Database className="w-4 h-4" />;
}
};
const getStatusColor = (status: string) => {
const statusConfig = dataSourceStatuses.find(s => s.key === status);
return statusConfig?.color || '#6b7280';
};
const getTypeColor = (type: string) => {
const typeConfig = dataSourceTypes.find(t => t.key === type);
return typeConfig?.color || '#6b7280';
};
return (
<Card className="p-6 bg-card hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${getTypeColor(dataSource.type)}20` }}
>
<Cloud
className="w-5 h-5"
style={{ color: getTypeColor(dataSource.type) }}
/>
</div>
<div>
<h3 className="font-semibold text-foreground">{dataSource.name}</h3>
<p className="text-sm text-muted-foreground">{dataSource.provider}</p>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(dataSource.status)}
<Badge
variant="outline"
className="font-light"
style={{
borderColor: getStatusColor(dataSource.status),
color: getStatusColor(dataSource.status),
}}
>
{dataSource.status}
</Badge>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<Badge
variant="outline"
className="font-light"
style={{
borderColor: getTypeColor(dataSource.type),
color: getTypeColor(dataSource.type),
}}
>
{dataSource.type}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<div className="flex items-center gap-2">
{getAccessMethodIcon(dataSource.accessMethod)}
<span className="text-sm">{dataSource.accessMethod}</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm">{dataSource.updateFrequency}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm font-medium">{dataSource.dataPoints.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm">{dataSource.lastUpdateTime}</span>
</div>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground line-clamp-2">
{dataSource.description}
</p>
</div>
<div className="flex flex-wrap gap-1 mb-4">
{dataSource.dataFields.slice(0, 3).map((field, index) => (
<Badge
key={index}
variant="secondary"
className="text-xs font-light"
>
{field}
</Badge>
))}
{dataSource.dataFields.length > 3 && (
<Badge variant="secondary" className="text-xs font-light">
+{dataSource.dataFields.length - 3}
</Badge>
)}
</div>
<div className="flex gap-2 pt-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={() => onView(dataSource)}
className="flex-1"
>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onEdit(dataSource)}
className="flex-1"
>
<Edit className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(dataSource.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
);
}

View File

@@ -0,0 +1,164 @@
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { ExternalDataState, ExternalDataAction } from './externalDataReducer';
import { dataSourceTypes, dataSourceStatuses } from '../types';
import { Search, Filter, X } from 'lucide-react';
interface FilterPanelProps {
state: ExternalDataState;
dispatch: React.Dispatch<ExternalDataAction>;
}
export function FilterPanel({ state, dispatch }: FilterPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
const uniqueProviders = Array.from(new Set(state.dataSources.map(ds => ds.provider)));
const handleFilterChange = (key: keyof ExternalDataState['filters'], value: any) => {
dispatch({ type: 'UPDATE_FILTER', payload: { key, value } });
};
const handleToggleFilter = (filterType: 'type' | 'status' | 'provider', value: string) => {
dispatch({ type: 'TOGGLE_ARRAY_FILTER', payload: { key: filterType, value } });
};
const clearAllFilters = () => {
dispatch({ type: 'CLEAR_FILTERS' });
};
const hasActiveFilters = Object.values(state.filters).some(
value => Array.isArray(value) ? value.length > 0 : value !== ''
);
const activeFilterCount = Object.values(state.filters).reduce(
(count, value) => count + (Array.isArray(value) ? value.length : (value ? 1 : 0)),
0
);
return (
<Card className="bg-card border-border">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<h3 className="font-medium"></h3>
{hasActiveFilters && (
<Badge variant="secondary" className="ml-2">
{activeFilterCount}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4 mr-1" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '收起' : '展开'}
</Button>
</div>
</div>
{/* 搜索框 */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索数据源名称、提供商..."
value={state.filters.searchTerm}
onChange={(e) => handleFilterChange('searchTerm', e.target.value)}
className="pl-10"
/>
</div>
{isExpanded && (
<div className="space-y-4">
{/* 数据类型筛选 */}
<div>
<Label className="text-sm font-medium mb-2 block"></Label>
<div className="flex flex-wrap gap-2">
{dataSourceTypes.map((type) => (
<Badge
key={type.key}
variant={state.filters.type.includes(type.key) ? 'default' : 'outline'}
className="cursor-pointer font-light"
style={{
backgroundColor: state.filters.type.includes(type.key) ? type.color : 'transparent',
borderColor: type.color,
color: state.filters.type.includes(type.key) ? 'white' : type.color,
}}
onClick={() => handleToggleFilter('type', type.key)}
>
{type.name}
</Badge>
))}
</div>
</div>
{/* 状态筛选 */}
<div>
<Label className="text-sm font-medium mb-2 block"></Label>
<div className="flex flex-wrap gap-2">
{dataSourceStatuses.map((status) => (
<Badge
key={status.key}
variant={state.filters.status.includes(status.key) ? 'default' : 'outline'}
className="cursor-pointer font-light"
style={{
backgroundColor: state.filters.status.includes(status.key) ? status.color : 'transparent',
borderColor: status.color,
color: state.filters.status.includes(status.key) ? 'white' : status.color,
}}
onClick={() => handleToggleFilter('status', status.key)}
>
{status.name}
</Badge>
))}
</div>
</div>
{/* 提供商筛选 */}
{uniqueProviders.length > 0 && (
<div>
<Label className="text-sm font-medium mb-2 block"></Label>
<div className="flex flex-wrap gap-2">
{uniqueProviders.map((provider) => (
<Badge
key={provider}
variant={state.filters.provider.includes(provider) ? 'default' : 'outline'}
className="cursor-pointer font-light"
style={{
backgroundColor: state.filters.provider.includes(provider) ? '#3b82f6' : 'transparent',
borderColor: '#3b82f6',
color: state.filters.provider.includes(provider) ? 'white' : '#3b82f6',
}}
onClick={() => handleToggleFilter('provider', provider)}
>
{provider}
</Badge>
))}
</div>
</div>
)}
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { Card } from '@/components/ui/card';
import { ExternalDataState } from './externalDataReducer';
import {
Database,
Cloud,
Activity,
Clock,
TrendingUp,
CheckCircle,
} from 'lucide-react';
interface StatisticsOverviewProps {
state: ExternalDataState;
}
export function StatisticsOverview({ state }: StatisticsOverviewProps) {
const activeRate = state.statistics.totalSources > 0
? (state.statistics.activeSources / state.statistics.totalSources * 100).toFixed(1)
: '0';
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 总数据源 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-600 dark:text-blue-400 font-light"></p>
<p className="text-2xl font-bold text-blue-700 dark:text-blue-300">
{state.statistics.totalSources}
</p>
</div>
<Database className="w-8 h-8 text-blue-500 dark:text-blue-400" />
</div>
</Card>
{/* 活跃数据源 */}
<Card className="p-4 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-green-600 dark:text-green-400 font-light"></p>
<p className="text-2xl font-bold text-green-700 dark:text-green-300">
{state.statistics.activeSources}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500 dark:text-green-400" />
</div>
</Card>
{/* 总数据点 */}
<Card className="p-4 bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-purple-600 dark:text-purple-400 font-light"></p>
<p className="text-2xl font-bold text-purple-700 dark:text-purple-300">
{state.statistics.totalDataPoints.toLocaleString()}
</p>
</div>
<Cloud className="w-8 h-8 text-purple-500 dark:text-purple-400" />
</div>
</Card>
{/* 活跃率 */}
<Card className="p-4 bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-orange-600 dark:text-orange-400 font-light"></p>
<p className="text-2xl font-bold text-orange-700 dark:text-orange-300">
{activeRate}%
</p>
</div>
<TrendingUp className="w-8 h-8 text-orange-500 dark:text-orange-400" />
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import { ExternalDataSource, DataSourceType, DataSourceStatus, AccessMethod } from '../types';
export interface ExternalDataState {
dataSources: ExternalDataSource[];
filters: {
type: string[];
status: string[];
provider: string[];
searchTerm: string;
};
selectedDataSource: ExternalDataSource | null;
showAddDialog: boolean;
showUploadDialog: boolean;
showEditDialog: boolean;
showDataPreviewDialog: boolean;
uploadedFile: File | null;
uploadProgress: number;
selectedAccessMethod: AccessMethod;
statistics: {
totalSources: number;
activeSources: number;
totalDataPoints: number;
lastUpdateTime: string;
};
}
export type ExternalDataAction =
| { type: 'SET_DATA_SOURCES'; payload: ExternalDataSource[] }
| { type: 'SET_FILTERS'; payload: Partial<ExternalDataState['filters']> }
| { type: 'UPDATE_FILTER'; payload: { key: keyof ExternalDataState['filters']; value: any } }
| { type: 'TOGGLE_ARRAY_FILTER'; payload: { key: 'type' | 'status' | 'provider'; value: string } }
| { type: 'CLEAR_FILTERS' }
| { type: 'SET_SELECTED_DATA_SOURCE'; payload: ExternalDataSource | null }
| { type: 'SHOW_ADD_DIALOG'; payload: boolean }
| { type: 'SHOW_UPLOAD_DIALOG'; payload: boolean }
| { type: 'SHOW_EDIT_DIALOG'; payload: boolean }
| { type: 'SHOW_DATA_PREVIEW_DIALOG'; payload: boolean }
| { type: 'SET_UPLOADED_FILE'; payload: File | null }
| { type: 'SET_UPLOAD_PROGRESS'; payload: number }
| { type: 'SET_SELECTED_ACCESS_METHOD'; payload: AccessMethod }
| { type: 'ADD_DATA_SOURCE'; payload: ExternalDataSource }
| { type: 'UPDATE_DATA_SOURCE'; payload: { id: string; updates: Partial<ExternalDataSource> } }
| { type: 'DELETE_DATA_SOURCE'; payload: string }
| { type: 'SET_STATISTICS'; payload: Partial<ExternalDataState['statistics']> };
export const initialState: ExternalDataState = {
dataSources: [],
filters: {
type: [],
status: [],
provider: [],
searchTerm: '',
},
selectedDataSource: null,
showAddDialog: false,
showUploadDialog: false,
showEditDialog: false,
showDataPreviewDialog: false,
uploadedFile: null,
uploadProgress: 0,
selectedAccessMethod: 'API对接',
statistics: {
totalSources: 0,
activeSources: 0,
totalDataPoints: 0,
lastUpdateTime: '',
},
};
export function externalDataReducer(state: ExternalDataState, action: ExternalDataAction): ExternalDataState {
switch (action.type) {
case 'SET_DATA_SOURCES':
return {
...state,
dataSources: action.payload,
};
case 'SET_FILTERS':
return {
...state,
filters: {
...state.filters,
...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: {
type: [],
status: [],
provider: [],
searchTerm: '',
},
};
case 'SET_SELECTED_DATA_SOURCE':
return {
...state,
selectedDataSource: action.payload,
};
case 'SHOW_ADD_DIALOG':
return {
...state,
showAddDialog: action.payload,
};
case 'SHOW_UPLOAD_DIALOG':
return {
...state,
showUploadDialog: action.payload,
};
case 'SHOW_EDIT_DIALOG':
return {
...state,
showEditDialog: action.payload,
};
case 'SHOW_DATA_PREVIEW_DIALOG':
return {
...state,
showDataPreviewDialog: action.payload,
};
case 'SET_UPLOADED_FILE':
return {
...state,
uploadedFile: action.payload,
};
case 'SET_UPLOAD_PROGRESS':
return {
...state,
uploadProgress: action.payload,
};
case 'SET_SELECTED_ACCESS_METHOD':
return {
...state,
selectedAccessMethod: action.payload,
};
case 'ADD_DATA_SOURCE':
return {
...state,
dataSources: [...state.dataSources, action.payload],
};
case 'UPDATE_DATA_SOURCE':
return {
...state,
dataSources: state.dataSources.map(ds =>
ds.id === action.payload.id
? { ...ds, ...action.payload.updates }
: ds
),
};
case 'DELETE_DATA_SOURCE':
return {
...state,
dataSources: state.dataSources.filter(ds => ds.id !== action.payload),
};
case 'SET_STATISTICS':
return {
...state,
statistics: {
...state.statistics,
...action.payload,
},
};
default:
return state;
}
}
export function calculateStatistics(dataSources: ExternalDataSource[]): ExternalDataState['statistics'] {
const totalSources = dataSources.length;
const activeSources = dataSources.filter(ds => ds.status === '正常').length;
const totalDataPoints = dataSources.reduce((sum, ds) => sum + ds.dataPoints, 0);
const lastUpdateTime = dataSources
.filter(ds => ds.status === '正常')
.map(ds => ds.lastUpdateTime)
.sort()
.pop() || '';
return {
totalSources,
activeSources,
totalDataPoints,
lastUpdateTime,
};
}

View File

@@ -0,0 +1,831 @@
/**
* filekorolheader: 外部数据源管理页面 - 多源数据接入管理中心
* 功能:外部数据源管理、数据质量监控、异常告警处理、数据接入配置
* 路径:/ai-crop-model/data-sense-center/external
* 规范遵循crop-x/docs/开发项目规范.md使用useState状态管理shadcn语义化样式
*/
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Database,
Plus,
Upload,
RefreshCw,
CheckCircle,
XCircle,
AlertCircle,
Clock,
Cloud,
Satellite,
Wifi,
WifiOff,
Activity,
Thermometer,
Droplet,
Sun,
Wind,
Camera,
Gauge,
Zap,
Settings,
Eye,
Edit,
Trash2,
Link,
PlayCircle,
PauseCircle,
BarChart3,
Calendar,
MapPin,
Signal,
CheckCircle2,
AlertTriangle,
TrendingUp,
Filter,
FileText,
Search,
Download,
} from 'lucide-react';
import { toast } from 'sonner';
type DataSourceType = '气象数据' | '卫星遥感' | '土壤数据' | '作物生长' | '其他';
type DataSourceStatus = '正常' | '异常' | '离线' | '待配置';
type AccessMethod = '手动上传' | 'API对接' | 'FTP传输' | 'WebSocket';
interface ExternalDataSource {
id: string;
name: string;
type: DataSourceType;
provider: string;
accessMethod: AccessMethod;
apiEndpoint?: string;
updateFrequency: string;
lastUpdateTime: string;
dataPoints: number;
status: DataSourceStatus;
dataFields: string[];
description: string;
}
interface DataQuality {
completeness: number;
accuracy: number;
timeliness: number;
consistency: number;
}
export default function ExternalPage() {
const [activeTab, setActiveTab] = useState('external');
const [showDataSourceDialog, setShowDataSourceDialog] = useState(false);
const [showUploadDialog, setShowUploadDialog] = useState(false);
const [showValidationDialog, setShowValidationDialog] = useState(false);
const [showDataPreviewDialog, setShowDataPreviewDialog] = useState(false);
const [selectedDataSource, setSelectedDataSource] = useState<ExternalDataSource | null>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [selectedAccessMethod, setSelectedAccessMethod] = useState<AccessMethod>('API对接');
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
// 外部数据源模拟数据
const [externalDataSources] = useState<ExternalDataSource[]>([
{
id: 'ext-1',
name: '国家气象局API',
type: '气象数据',
provider: '中国气象局',
accessMethod: 'API对接',
apiEndpoint: 'https://api.weather.gov.cn/v1/data',
updateFrequency: '每小时',
lastUpdateTime: '2024-10-15 14:00:00',
dataPoints: 24850,
status: '正常',
dataFields: ['温度', '湿度', '气压', '降水量', '风速', '风向'],
description: '实时气象数据,包含温度、湿度、降水等多维度信息',
},
{
id: 'ext-2',
name: 'Sentinel-2卫星数据',
type: '卫星遥感',
provider: 'ESA欧空局',
accessMethod: 'API对接',
apiEndpoint: 'https://scihub.copernicus.eu/dhus',
updateFrequency: '每5天',
lastUpdateTime: '2024-10-12 08:30:00',
dataPoints: 1280,
status: '正常',
dataFields: ['NDVI', 'EVI', 'LAI', '地表温度', '土壤湿度指数'],
description: '高分辨率卫星遥感影像,用于作物长势监测',
},
{
id: 'ext-3',
name: '土壤数据库',
type: '土壤数据',
provider: '农业部土壤监测中心',
accessMethod: '手动上传',
updateFrequency: '每季度',
lastUpdateTime: '2024-09-20 10:15:00',
dataPoints: 385,
status: '正常',
dataFields: ['pH值', '有机质', '氮磷钾含量', '土壤质地', '盐分'],
description: '区域土壤理化性质数据',
},
{
id: 'ext-4',
name: '光照辐射数据',
type: '气象数据',
provider: '光伏气象站网络',
accessMethod: 'FTP传输',
updateFrequency: '每30分钟',
lastUpdateTime: '2024-10-15 13:30:00',
dataPoints: 15620,
status: '正常',
dataFields: ['总辐射', '直接辐射', '散射辐射', '光合有效辐射'],
description: '太阳辐射数据,用于光合作用分析',
},
{
id: 'ext-5',
name: '作物生长监测',
type: '作物生长',
provider: '农业科学院',
accessMethod: 'API对接',
updateFrequency: '每周',
lastUpdateTime: '2024-10-14 09:00:00',
dataPoints: 2450,
status: '异常',
dataFields: ['株高', '叶面积指数', '生物量', '产量预测'],
description: '作物生长参数动态监测数据',
},
]);
// 数据质量评估
const [dataQuality] = useState<DataQuality>({
completeness: 94.5,
accuracy: 96.8,
timeliness: 92.3,
consistency: 95.2,
});
// 统计数据
const totalExternalSources = externalDataSources.length;
const activeExternalSources = externalDataSources.filter(s => s.status === '正常').length;
// 过滤数据源
const filteredDataSources = externalDataSources.filter(source => {
const matchesSearch = source.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
source.provider.toLowerCase().includes(searchTerm.toLowerCase()) ||
source.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = selectedType === 'all' || source.type === selectedType;
const matchesStatus = selectedStatus === 'all' || source.status === selectedStatus;
return matchesSearch && matchesType && matchesStatus;
});
const getStatusColor = (status: DataSourceStatus) => {
switch (status) {
case '正常':
return 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/50 dark:text-green-300';
case '异常':
return 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/50 dark:text-red-300';
case '离线':
return 'bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/50 dark:text-gray-300';
case '待配置':
return 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/50 dark:text-yellow-300';
default:
return 'bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/50 dark:text-gray-300';
}
};
const getStatusIcon = (status: DataSourceStatus) => {
switch (status) {
case '正常':
return <CheckCircle className="w-4 h-4 text-green-600" />;
case '异常':
return <XCircle className="w-4 h-4 text-red-600" />;
case '离线':
return <WifiOff className="w-4 h-4 text-gray-500" />;
case '待配置':
return <AlertCircle className="w-4 h-4 text-yellow-600" />;
default:
return <AlertCircle className="w-4 h-4 text-gray-500" />;
}
};
const getDataTypeIcon = (type: DataSourceType) => {
switch (type) {
case '气象数据':
return <Cloud className="w-4 h-4 text-blue-600" />;
case '卫星遥感':
return <Satellite className="w-4 h-4 text-purple-600" />;
case '土壤数据':
return <Database className="w-4 h-4 text-green-600" />;
case '作物生长':
return <TrendingUp className="w-4 h-4 text-orange-600" />;
default:
return <Database className="w-4 h-4 text-gray-600" />;
}
};
const getAccessMethodIcon = (method: string) => {
switch (method) {
case 'API对接':
return <Link className="w-4 h-4" />;
case 'FTP传输':
return <Upload className="w-4 h-4" />;
case 'WebSocket':
return <Wifi className="w-4 h-4" />;
case '手动上传':
return <Upload className="w-4 h-4" />;
default:
return <Database className="w-4 h-4" />;
}
};
const handleTestConnection = () => {
toast.success('连接测试成功,数据接入正常');
};
const handleDataPreview = () => {
setShowDataPreviewDialog(true);
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files && files.length > 0) {
const fileArray = Array.from(files);
setUploadedFiles(fileArray);
setUploadedFile(fileArray[0]);
let progress = 0;
const interval = setInterval(() => {
progress += 10;
setUploadProgress(progress);
if (progress >= 100) {
clearInterval(interval);
toast.success(`成功上传${fileArray.length}个文件`);
}
}, 200);
}
};
return (
<div className="p-6 space-y-6 bg-background">
{/* 页面标题和说明 */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<Database className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-3xl text-sky-600">{activeExternalSources}/{totalExternalSources}</p>
<p className="text-xs text-sky-600 mt-1"></p>
</div>
<Database className="w-12 h-12 text-sky-600 opacity-50" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-3xl text-green-600">3/5</p>
<p className="text-xs text-green-600 mt-1">线</p>
</div>
<Wifi className="w-12 h-12 text-green-600 opacity-50" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-3xl text-green-600">{dataQuality.accuracy}%</p>
<p className="text-xs text-green-600 mt-1"></p>
</div>
<CheckCircle2 className="w-12 h-12 text-green-600 opacity-50" />
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="mt-2 text-3xl text-amber-600">2</p>
<p className="text-xs text-amber-600 mt-1"></p>
</div>
<AlertTriangle className="w-12 h-12 text-amber-600 opacity-50" />
</div>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="external"></TabsTrigger>
<TabsTrigger value="iot"></TabsTrigger>
</TabsList>
{/* 多源数据接入 */}
<TabsContent value="external" className="space-y-4">
<Card className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200 dark:from-blue-950/50 dark:to-indigo-950/50 dark:border-blue-800">
<div className="flex items-start gap-2">
<Database className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="mb-2 font-medium"></p>
<ul className="space-y-1 text-xs">
<li> <strong></strong>: API对接FTP传输等多种方式</li>
<li> <strong></strong>: </li>
<li> <strong></strong>: 湿</li>
<li> <strong></strong>: NDVI植被指数</li>
<li> <strong></strong>: </li>
</ul>
</div>
</div>
</Card>
<div className="flex gap-4">
<Button onClick={() => {
setSelectedDataSource(null);
setSelectedAccessMethod('API对接');
setUploadedFile(null);
setUploadedFiles([]);
setUploadProgress(0);
setShowDataSourceDialog(true);
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => setShowUploadDialog(true)}>
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => setShowValidationDialog(true)}>
<CheckCircle2 className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 筛选条件 */}
<Card className="p-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Search className="w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索数据源..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-64"
/>
</div>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-40">
<SelectValue placeholder="数据类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="气象数据"></SelectItem>
<SelectItem value="卫星遥感"></SelectItem>
<SelectItem value="土壤数据"></SelectItem>
<SelectItem value="作物生长"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="正常"></SelectItem>
<SelectItem value="异常"></SelectItem>
<SelectItem value="离线">线</SelectItem>
<SelectItem value="待配置"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
{/* 数据源列表 */}
<Card className="bg-card border-border">
<div className="p-4 border-b border-border bg-muted/30">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<Database className="w-5 h-5 text-blue-600" />
</h3>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
<TableHead className="font-medium"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDataSources.map((source) => (
<TableRow key={source.id} className="hover:bg-muted/30">
<TableCell>
<div className="flex items-center gap-2">
{getDataTypeIcon(source.type)}
<span className="font-medium">{source.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="font-light">{source.type}</Badge>
</TableCell>
<TableCell className="text-xs">{source.provider}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getAccessMethodIcon(source.accessMethod)}
<Badge variant="outline" className="font-light">{source.accessMethod}</Badge>
</div>
</TableCell>
<TableCell className="text-xs">{source.updateFrequency}</TableCell>
<TableCell className="text-xs">{source.dataPoints.toLocaleString()}</TableCell>
<TableCell className="text-xs">{source.lastUpdateTime}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getStatusIcon(source.status)}
<Badge className={`font-light ${getStatusColor(source.status)}`}>{source.status}</Badge>
</div>
</TableCell>
<TableCell>
<div className="flex gap-2">
{source.accessMethod !== '手动上传' && (
<Button size="sm" variant="outline" onClick={handleTestConnection}>
<Link className="w-3 h-3" />
</Button>
)}
<Button size="sm" variant="outline" onClick={() => {
setSelectedDataSource(source);
setSelectedAccessMethod(source.accessMethod);
setShowDataSourceDialog(true);
}}>
<Edit className="w-3 h-3" />
</Button>
<Button size="sm" variant="outline" onClick={handleDataPreview}>
<Eye className="w-3 h-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</Card>
</TabsContent>
</Tabs>
{/* 数据源配置对话框 */}
<Dialog open={showDataSourceDialog} onOpenChange={setShowDataSourceDialog}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
{selectedDataSource ? '编辑数据源' : '添加数据源'}
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Input placeholder="输入数据源名称" defaultValue={selectedDataSource?.name} />
</div>
<div>
<Label></Label>
<Select defaultValue={selectedDataSource?.type}>
<SelectTrigger>
<SelectValue placeholder="选择数据类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="气象数据"></SelectItem>
<SelectItem value="卫星遥感"></SelectItem>
<SelectItem value="土壤数据"></SelectItem>
<SelectItem value="作物生长"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Input placeholder="输入数据提供商" defaultValue={selectedDataSource?.provider} />
</div>
<div>
<Label></Label>
<Select value={selectedAccessMethod} onValueChange={(value: AccessMethod) => setSelectedAccessMethod(value)}>
<SelectTrigger>
<SelectValue placeholder="选择接入方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="API对接">API对接</SelectItem>
<SelectItem value="FTP传输">FTP传输</SelectItem>
<SelectItem value="WebSocket">WebSocket</SelectItem>
<SelectItem value="手动上传"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{selectedAccessMethod !== '手动上传' && (
<div>
<Label>API端点/</Label>
<Input placeholder="输入API端点或服务器地址" defaultValue={selectedDataSource?.apiEndpoint} />
</div>
)}
<div>
<Label></Label>
<Select defaultValue={selectedDataSource?.updateFrequency}>
<SelectTrigger>
<SelectValue placeholder="选择更新频率" />
</SelectTrigger>
<SelectContent>
<SelectItem value="实时"></SelectItem>
<SelectItem value="每5分钟">5</SelectItem>
<SelectItem value="每30分钟">30</SelectItem>
<SelectItem value="每小时"></SelectItem>
<SelectItem value="每天"></SelectItem>
<SelectItem value="每周"></SelectItem>
<SelectItem value="每月"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Textarea
placeholder="输入数据源的详细描述"
className="resize-none"
rows={3}
defaultValue={selectedDataSource?.description}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDataSourceDialog(false)}>
</Button>
<Button onClick={() => {
toast.success(selectedDataSource ? '数据源更新成功' : '数据源添加成功');
setShowDataSourceDialog(false);
}}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 手动上传数据对话框 */}
<Dialog open={showUploadDialog} onOpenChange={setShowUploadDialog}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-blue-600" />
</DialogTitle>
<DialogDescription>
CSVJSONXML等多种格式数据上传
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label></Label>
<Input
type="file"
multiple
accept=".csv,.json,.xml,.xlsx"
onChange={handleFileUpload}
/>
</div>
{uploadProgress > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm"></span>
<span className="text-sm font-medium">{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<Card className="p-4 bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800">
<h4 className="text-sm mb-3 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-blue-600" />
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs"></span>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<span className="text-xs"></span>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<span className="text-xs"></span>
<Switch defaultChecked />
</div>
</div>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUploadDialog(false)}>
</Button>
<Button onClick={() => {
toast.success('数据上传成功');
setShowUploadDialog(false);
}}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 数据校验规则对话框 */}
<Dialog open={showValidationDialog} onOpenChange={setShowValidationDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm"></Label>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<Label className="text-sm"></Label>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<Label className="text-sm"></Label>
<Switch defaultChecked />
</div>
<div className="flex items-center justify-between">
<Label className="text-sm"></Label>
<Switch defaultChecked />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowValidationDialog(false)}>
</Button>
<Button onClick={() => {
toast.success('校验规则保存成功');
setShowValidationDialog(false);
}}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 数据预览对话框 */}
<Dialog open={showDataPreviewDialog} onOpenChange={setShowDataPreviewDialog}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Eye className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{selectedDataSource && (
<>
<Card className="p-4">
<h4 className="font-medium mb-3">{selectedDataSource.name}</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2">{selectedDataSource.type}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2">{selectedDataSource.provider}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2">{selectedDataSource.accessMethod}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2">{selectedDataSource.updateFrequency}</span>
</div>
</div>
<div className="mt-3">
<span className="text-muted-foreground"></span>
<p className="mt-1 text-sm">{selectedDataSource.description}</p>
</div>
</Card>
<Card className="p-4">
<h4 className="font-medium mb-3"></h4>
<div className="flex flex-wrap gap-2">
{selectedDataSource.dataFields.map((field, index) => (
<Badge key={index} variant="outline">{field}</Badge>
))}
</div>
</Card>
<Card className="p-4">
<h4 className="font-medium mb-3"></h4>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2 font-medium">{selectedDataSource.dataPoints.toLocaleString()}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2">{selectedDataSource.lastUpdateTime}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<Badge className={`ml-2 ${getStatusColor(selectedDataSource.status)}`}>
{selectedDataSource.status}
</Badge>
</div>
</div>
</Card>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDataPreviewDialog(false)}>
</Button>
<Button>
<Download className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,115 @@
export type DataSourceType = '气象数据' | '卫星遥感' | '土壤数据' | '作物生长' | '其他';
export type DataSourceStatus = '正常' | '异常' | '离线' | '待配置';
export type AccessMethod = '手动上传' | 'API对接' | 'FTP传输' | 'WebSocket';
export interface ExternalDataSource {
id: string;
name: string;
type: DataSourceType;
provider: string;
accessMethod: AccessMethod;
apiEndpoint?: string;
updateFrequency: string;
lastUpdateTime: string;
dataPoints: number;
status: DataSourceStatus;
dataFields: string[];
description: string;
}
export interface DataSourceForm {
name: string;
type: DataSourceType;
provider: string;
accessMethod: AccessMethod;
apiEndpoint?: string;
updateFrequency: string;
description: string;
}
export const dataSourceTypes: Array<{ key: DataSourceType; name: string; color: string }> = [
{ key: '气象数据', name: '气象数据', color: '#3b82f6' },
{ key: '卫星遥感', name: '卫星遥感', color: '#10b981' },
{ key: '土壤数据', name: '土壤数据', color: '#8b5cf6' },
{ key: '作物生长', name: '作物生长', color: '#f59e0b' },
{ key: '其他', name: '其他', color: '#6b7280' },
];
export const dataSourceStatuses: Array<{ key: DataSourceStatus; name: string; color: string }> = [
{ key: '正常', name: '正常', color: '#10b981' },
{ key: '异常', name: '异常', color: '#ef4444' },
{ key: '离线', name: '离线', color: '#6b7280' },
{ key: '待配置', name: '待配置', color: '#f59e0b' },
];
export const accessMethods: AccessMethod[] = ['手动上传', 'API对接', 'FTP传输', 'WebSocket'];
export const sampleDataSources: ExternalDataSource[] = [
{
id: 'ext-1',
name: '国家气象局API',
type: '气象数据',
provider: '中国气象局',
accessMethod: 'API对接',
apiEndpoint: 'https://api.weather.gov.cn/v1/data',
updateFrequency: '每小时',
lastUpdateTime: '2024-10-15 14:00:00',
dataPoints: 24850,
status: '正常',
dataFields: ['温度', '湿度', '气压', '降水量', '风速', '风向'],
description: '实时气象数据,包含温度、湿度、降水等多维度信息',
},
{
id: 'ext-2',
name: 'Sentinel-2卫星数据',
type: '卫星遥感',
provider: 'ESA欧空局',
accessMethod: 'API对接',
apiEndpoint: 'https://scihub.copernicus.eu/dhus',
updateFrequency: '每5天',
lastUpdateTime: '2024-10-12 08:30:00',
dataPoints: 1280,
status: '正常',
dataFields: ['NDVI', 'EVI', 'LAI', '地表温度', '土壤湿度指数'],
description: '高分辨率卫星遥感影像,用于作物长势监测',
},
{
id: 'ext-3',
name: '土壤数据库',
type: '土壤数据',
provider: '农业部土壤监测中心',
accessMethod: '手动上传',
updateFrequency: '每季度',
lastUpdateTime: '2024-09-20 10:15:00',
dataPoints: 385,
status: '正常',
dataFields: ['pH值', '有机质', '氮磷钾含量', '土壤质地', '盐分'],
description: '区域土壤理化性质数据',
},
{
id: 'ext-4',
name: '光照辐射数据',
type: '气象数据',
provider: '光伏气象站网络',
accessMethod: 'FTP传输',
updateFrequency: '每30分钟',
lastUpdateTime: '2024-10-15 13:30:00',
dataPoints: 15620,
status: '正常',
dataFields: ['总辐射', '直接辐射', '散射辐射', '光合有效辐射'],
description: '太阳辐射数据,用于光合作用分析',
},
{
id: 'ext-5',
name: '作物生长监测系统',
type: '作物生长',
provider: '农业大学作物研究所',
accessMethod: 'WebSocket',
updateFrequency: '实时',
lastUpdateTime: '2024-10-15 14:05:00',
dataPoints: 8920,
status: '异常',
dataFields: ['株高', '叶面积指数', '生物量', '产量预测', '病虫害指数'],
description: '作物生长实时监测数据,提供生长预测分析',
},
];

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -977,17 +977,27 @@ const aiCropModel = {
navMain: [ navMain: [
{ {
title: "全域数据感知中心", title: "全域数据感知中心",
url: "/ai-crop-model/data-center", url: "/ai-crop-model/data-sense-center",
icon: <Cloud className="w-4 h-4" />, icon: <Cloud className="w-4 h-4" />,
items: [ items: [
{ {
title: "外部数据", title: "多源数据接入",
url: "/ai-crop-model/data-center/external", url: "/ai-crop-model/data-sense-center/external",
isActive: false isActive: false
}, },
{ {
title: "IoT数据", title: "物联设备数据接入",
url: "/ai-crop-model/data-center/iot", url: "/ai-crop-model/data-sense-center/iot",
isActive: false
},
{
title: "设备类型管理",
url: "/ai-crop-model/data-sense-center/device-type",
isActive: false
},
{
title: "设备参数管理",
url: "/ai-crop-model/data-sense-center/device-parameter",
isActive: false isActive: false
} }
] ]

View File

@@ -14,5 +14,5 @@ import type { ClientOptions as ClientOptions2 } from './types.gen';
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>; export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ export const client = createClient(createConfig<ClientOptions2>({
baseUrl: 'http://pengcode.tech:8080' baseUrl: 'https://gitea-admin-hm-smart-agri-app.dev.maimaiag.com'
})); }));

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

1233
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,11 +35,14 @@
"date-fns": "*", "date-fns": "*",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^3.0.3",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"qrcode": "*", "qrcode": "*",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",

View File

@@ -523,7 +523,7 @@ export function AIDataCenter({ activePath }: AIDataCenterProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2></h2> <h2></h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
</p> </p>
@@ -1246,118 +1246,269 @@ export function AIDataCenter({ activePath }: AIDataCenterProps) {
{/* 设备配置对话框 */} {/* 设备配置对话框 */}
<Dialog open={showDeviceDialog} onOpenChange={setShowDeviceDialog}> <Dialog open={showDeviceDialog} onOpenChange={setShowDeviceDialog}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{selectedDevice ? '编辑设备' : '添加设备'}</DialogTitle> <DialogTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
{selectedDevice ? '编辑设备配置' : '添加新设备'}
</DialogTitle>
<DialogDescription> <DialogDescription>
IoT设备的基本信息
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-4"> {/* 基本信息 */}
<div> <div className="space-y-4">
<Label></Label> <h4 className="text-lg font-medium flex items-center gap-2">
<Input placeholder="自动生成或手动输入" defaultValue={selectedDevice?.code} /> <Settings className="w-4 h-4" />
</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<Label></Label>
<Input placeholder="如: WS-2024-001" defaultValue={selectedDevice?.code} />
</div>
<div>
<Label></Label>
<Input placeholder="如: 1号气象站" defaultValue={selectedDevice?.name} />
</div>
<div>
<Label></Label>
{deviceTypes.length > 0 ? (
<Select defaultValue={selectedDevice?.deviceTypeId}>
<SelectTrigger>
<SelectValue placeholder="选择设备类型" />
</SelectTrigger>
<SelectContent>
{deviceTypes.map(type => (
<SelectItem key={type.id} value={type.id}>
{type.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select>
<SelectTrigger>
<SelectValue placeholder="选择设备类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="weather_station"></SelectItem>
<SelectItem value="soil_sensor"></SelectItem>
<SelectItem value="camera"></SelectItem>
<SelectItem value="water_sensor"></SelectItem>
<SelectItem value="env_station"></SelectItem>
</SelectContent>
</Select>
)}
</div>
</div> </div>
<div>
<Label></Label> <div className="grid grid-cols-2 gap-4">
<Input placeholder="输入设备名称" defaultValue={selectedDevice?.name} /> <div>
<Label></Label>
<Select defaultValue={selectedDevice?.manufacturer}>
<SelectTrigger>
<SelectValue placeholder="选择制造商" />
</SelectTrigger>
<SelectContent>
<SelectItem value="华为"></SelectItem>
<SelectItem value="中科院"></SelectItem>
<SelectItem value="海康威视"></SelectItem>
<SelectItem value="绿盟科技">绿</SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input placeholder="如: HW-WS-Pro" defaultValue={selectedDevice?.model} />
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* 位置信息 */}
<div> <div className="space-y-4">
<Label></Label> <h4 className="text-lg font-medium flex items-center gap-2">
<Select defaultValue={selectedDevice?.deviceTypeId}> <MapPin className="w-4 h-4" />
<SelectTrigger>
<SelectValue placeholder="选择设备类型" /> </h4>
</SelectTrigger> <div className="grid grid-cols-2 gap-4">
<SelectContent> <div>
{deviceTypes.length > 0 ? ( <Label></Label>
deviceTypes.map(type => ( <Select defaultValue={selectedDevice?.fieldId}>
<SelectItem key={type.id} value={type.id}> <SelectTrigger>
{type.name} <SelectValue placeholder="选择所属地块" />
</SelectTrigger>
<SelectContent>
{fieldsList.map(field => (
<SelectItem key={field.id} value={field.id}>
{field.name} ({field.cropType} · {field.area})
</SelectItem> </SelectItem>
)) ))}
) : ( </SelectContent>
<SelectItem value="" disabled></SelectItem> </Select>
)} <p className="text-xs text-muted-foreground mt-1">
</SelectContent>
</Select> </p>
</div> </div>
<div> <div>
<Label></Label> <Label></Label>
<Input placeholder="输入设备型号" defaultValue={selectedDevice?.model} /> <Input placeholder="如: 1号大棚北侧" defaultValue={selectedDevice?.location} />
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* 通信配置 */}
<div> <div className="space-y-4">
<Label></Label> <h4 className="text-lg font-medium flex items-center gap-2">
<Input placeholder="输入厂家名称" defaultValue={selectedDevice?.manufacturer} /> <Wifi className="w-4 h-4" />
</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Select defaultValue={selectedDevice?.protocol}>
<SelectTrigger>
<SelectValue placeholder="选择通信协议" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MQTT">MQTT</SelectItem>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="CoAP">CoAP</SelectItem>
<SelectItem value="WebSocket">WebSocket</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Select defaultValue={selectedDevice?.dataFrequency}>
<SelectTrigger>
<SelectValue placeholder="选择数据频率" />
</SelectTrigger>
<SelectContent>
<SelectItem value="实时"></SelectItem>
<SelectItem value="每1分钟">1</SelectItem>
<SelectItem value="每5分钟">5</SelectItem>
<SelectItem value="每10分钟">10</SelectItem>
<SelectItem value="每30分钟">30</SelectItem>
<SelectItem value="每小时"></SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<div>
<Label></Label> <div className="grid grid-cols-2 gap-4">
<Select defaultValue={selectedDevice?.fieldId}> <div>
<SelectTrigger> <Label>IP地址</Label>
<SelectValue placeholder="选择地块" /> <Input placeholder="如: 192.168.1.100" defaultValue={selectedDevice?.ipAddress} />
</SelectTrigger> </div>
<SelectContent> <div>
{fieldsList.map(field => ( <Label>MQTT主题</Label>
<SelectItem key={field.id} value={field.id}> <Input placeholder="如: farm/weather/station01" defaultValue={selectedDevice?.mqttTopic} />
{field.name} ({field.cropType} · {field.area}) </div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* 传感器配置 */}
<div> <div className="space-y-4">
<Label></Label> <h4 className="text-lg font-medium flex items-center gap-2">
<Select defaultValue={selectedDevice?.protocol}> <Gauge className="w-4 h-4" />
<SelectTrigger>
<SelectValue placeholder="选择协议" /> </h4>
</SelectTrigger> <div className="space-y-3">
<SelectContent> <div className="grid grid-cols-4 gap-4 text-sm font-medium text-muted-foreground">
<SelectItem value="MQTT">MQTT</SelectItem> <div></div>
<SelectItem value="HTTP">HTTP</SelectItem> <div></div>
<SelectItem value="CoAP">CoAP</SelectItem> <div></div>
<SelectItem value="WebSocket">WebSocket</SelectItem> <div></div>
</SelectContent> </div>
</Select>
{/* 默认传感器配置 */}
<div className="grid grid-cols-4 gap-4 items-center">
<Input placeholder="温度" defaultValue="温度" />
<Input placeholder="℃" defaultValue="℃" />
<Input placeholder="-10~40" defaultValue="-10~40" />
<Switch defaultChecked />
</div>
<div className="grid grid-cols-4 gap-4 items-center">
<Input placeholder="湿度" defaultValue="湿度" />
<Input placeholder="%" defaultValue="%" />
<Input placeholder="0~100" defaultValue="0~100" />
<Switch defaultChecked />
</div>
<div className="grid grid-cols-4 gap-4 items-center">
<Input placeholder="气压" defaultValue="气压" />
<Input placeholder="hPa" defaultValue="hPa" />
<Input placeholder="950~1050" defaultValue="950~1050" />
<Switch defaultChecked />
</div>
</div> </div>
<div> <Button variant="outline" className="w-full">
<Label></Label> <Plus className="w-4 h-4 mr-2" />
<Select defaultValue={selectedDevice?.dataFrequency}>
<SelectTrigger> </Button>
<SelectValue placeholder="选择上报频率" /> </div>
</SelectTrigger>
<SelectContent> {/* 绑定配置 */}
<SelectItem value="实时"></SelectItem> <div className="space-y-4">
<SelectItem value="每分钟"></SelectItem> <h4 className="text-lg font-medium flex items-center gap-2">
<SelectItem value="每5分钟">5</SelectItem> <Link className="w-4 h-4" />
<SelectItem value="每10分钟">10</SelectItem>
<SelectItem value="每30分钟">30</SelectItem> </h4>
</SelectContent> <div className="grid grid-cols-2 gap-4">
</Select> <div>
<Label></Label>
<Select defaultValue={selectedDevice?.bindingStatus}>
<SelectTrigger>
<SelectValue placeholder="选择绑定状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="未绑定"></SelectItem>
<SelectItem value="已绑定"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Select defaultValue={selectedDevice?.bindingSystem}>
<SelectTrigger>
<SelectValue placeholder="选择绑定系统" />
</SelectTrigger>
<SelectContent>
<SelectItem value="智能灌溉控制系统"></SelectItem>
<SelectItem value="视频监控系统"></SelectItem>
<SelectItem value="环境监测系统"></SelectItem>
<SelectItem value="生产管理系统"></SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
<div> {/* 备注信息 */}
<Label>MQTT主题/IP地址</Label> <div className="space-y-4">
<Input placeholder="输入MQTT主题或IP地址" defaultValue={selectedDevice?.mqttTopic || selectedDevice?.ipAddress} /> <h4 className="text-lg font-medium flex items-center gap-2">
<FileText className="w-4 h-4" />
</h4>
<div>
<Label></Label>
<Textarea
placeholder="请输入设备的详细描述信息,包括功能特点、安装要求等"
className="resize-none"
rows={3}
defaultValue={selectedDevice ? `${selectedDevice.manufacturer} ${selectedDevice.model}${selectedDevice.sensors.length}个传感器` : ''}
/>
</div>
</div> </div>
<div className="flex items-center justify-between p-4 bg-success/10 rounded-lg"> {/* 启用和测试 */}
<div className="flex items-center justify-between p-4 bg-green-50 dark:bg-green-950/30 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch defaultChecked /> <Switch defaultChecked />
<span className="text-sm"></span> <span className="text-sm font-medium"></span>
</div> </div>
<Button size="sm" variant="outline" onClick={handleTestConnection}> <Button size="sm" variant="outline" onClick={handleTestConnection}>
<Link className="w-3 h-3 mr-1" /> <Link className="w-3 h-3 mr-1" />
@@ -1366,14 +1517,14 @@ export function AIDataCenter({ activePath }: AIDataCenterProps) {
</div> </div>
</div> </div>
<DialogFooter> <div className="flex justify-end gap-3 pt-4 border-t">
<Button variant="outline" onClick={() => setShowDeviceDialog(false)}> <Button variant="outline" onClick={() => setShowDeviceDialog(false)}>
</Button> </Button>
<Button className="bg-success hover:bg-success/90" onClick={handleSaveDevice}> <Button className="bg-green-600 hover:bg-green-700" onClick={handleSaveDevice}>
{selectedDevice ? '保存配置' : '添加设备'}
</Button> </Button>
</DialogFooter> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>