| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597 |
- import React, { useState, useEffect } from 'react';
- import {
- Button, Input, message,
- Card, Spin, Badge, Descriptions,
- Tag, Radio, Tabs, List, Alert, Empty, Drawer,
- Tree
- } from 'antd';
- import {
- MenuFoldOutlined,
- MenuUnfoldOutlined,
- AppstoreOutlined,
- EnvironmentOutlined,
- SearchOutlined
- } from '@ant-design/icons';
- import {
- useQuery,
- useQueryClient
- } from '@tanstack/react-query';
- import 'dayjs/locale/zh-cn';
- import AMap from './components_amap.tsx'; // 导入地图组件
- // 从share/types.ts导入所有类型,包括MapMode
- import type {
- MapViewDevice, DeviceMapFilter, DeviceMapStats,
- DeviceTreeNode, DeviceTreeStats
- } from '../share/monitorTypes.ts';
- import {
- DeviceStatus, DeviceTreeNodeType
- } from '../share/monitorTypes.ts';
- import { MonitorAPI} from './api/index.ts';
- import { MapMode } from "../share/types.ts";
- // 设备树组件接口定义
- interface DeviceTreeProps {
- onSelect: (node: {
- key: string;
- type: DeviceTreeNodeType;
- data?: {
- total: number;
- online: number;
- offline: number;
- error: number;
- }
- }) => void;
- selectedKey: string | null;
- statusFilter: string;
- onStatusFilterChange: (status: string) => void;
- }
- // 设备树组件
- const DeviceTree = ({ onSelect, selectedKey, statusFilter, onStatusFilterChange }: DeviceTreeProps) => {
- const [searchValue, setSearchValue] = useState('');
- // 使用React Query获取设备树数据
- const { data: treeData = [], isLoading: treeLoading } = useQuery<DeviceTreeNode[]>({
- queryKey: ['deviceTree', statusFilter, searchValue],
- queryFn: async () => {
- try {
- // 构建API参数
- const params: {
- status?: string,
- keyword?: string
- } = {};
-
- // 添加状态过滤
- if (statusFilter !== 'all') {
- params.status = statusFilter;
- }
-
- // 添加关键词搜索
- if (searchValue) {
- params.keyword = searchValue;
- }
-
- const response = await MonitorAPI.getDeviceTree(params);
- return response.data || [];
- } catch (error) {
- console.error("获取设备树失败:", error);
- message.error("获取设备树失败");
- return [];
- }
- },
- refetchInterval: 30000 // 30秒刷新一次
- });
- // 使用React Query获取设备统计数据
- const { data: statistics = {} as DeviceTreeStats } = useQuery<DeviceTreeStats>({
- queryKey: ['deviceTreeStats'],
- queryFn: async () => {
- try {
- const response = await MonitorAPI.getDeviceTreeStats();
- return response.data || {};
- } catch (error) {
- console.error("获取设备统计失败:", error);
- message.error("获取设备统计失败");
- return {};
- }
- },
- refetchInterval: 30000 // 30秒刷新一次
- });
- // 直接使用从后端获取的已过滤树数据
- const filteredTreeData = treeData;
- // 处理树节点标题渲染
- const renderTreeTitle = (node: DeviceTreeNode) => {
- const stats = statistics[node.key] || {
- total: 0,
- online: 0,
- offline: 0,
- error: 0
- };
- return (
- <div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', paddingRight: 8 }}>
- <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
- {node.icon && (
- <img
- src={node.icon}
- alt={node.title}
- style={{
- width: 16,
- height: 16,
- filter: node.status === 'offline' ? 'grayscale(100%)' : 'none'
- }}
- />
- )}
- <span style={{ color: node.status === 'offline' ? '#999' : 'inherit' }}>
- {node.title}
- </span>
- </span>
- {node.type === 'category' ? (
- <div style={{ display: 'flex', gap: 8, fontSize: 12 }}>
- {stats.error > 0 && (
- <span style={{ color: '#f5222d' }}>{stats.error}</span>
- )}
- {stats.offline > 0 && (
- <span style={{ color: '#999' }}>{stats.offline}</span>
- )}
- {stats.online > 0 && (
- <span style={{ color: '#52c41a' }}>{stats.online}</span>
- )}
- <span style={{ color: '#ccc' }}>/{stats.total}</span>
- </div>
- ) : (
- <div style={{ display: 'flex', gap: 8, fontSize: 12 }}>
- {node.status === 'error' && (
- <span style={{ color: '#f5222d' }}>异常</span>
- )}
- {node.status === 'warning' && (
- <span style={{ color: '#faad14' }}>警告</span>
- )}
- {node.status === 'offline' && (
- <span style={{ color: '#999' }}>离线</span>
- )}
- {node.status === 'normal' && (
- <span style={{ color: '#52c41a' }}>正常</span>
- )}
- </div>
- )}
- </div>
- );
- };
- return (
- <Card
- style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
- variant="borderless"
- styles={{body:{ height: '100%', display: 'flex', flexDirection: 'column' }}}
- >
- <div style={{ marginBottom: 16 }}>
- <Radio.Group
- value={statusFilter}
- onChange={e => onStatusFilterChange(e.target.value)}
- style={{ width: '100%', display: 'flex', marginBottom: 12 }}
- size="small"
- >
- <Radio.Button value="error" style={{ flex: 1, textAlign: 'center' }}>
- 异常
- </Radio.Button>
- <Radio.Button value="normal" style={{ flex: 1, textAlign: 'center' }}>
- 正常
- </Radio.Button>
- <Radio.Button value="all" style={{ flex: 1, textAlign: 'center' }}>
- 全部
- </Radio.Button>
- </Radio.Group>
- <Input.Search
- placeholder="搜索设备分类或设备"
- allowClear
- onChange={e => setSearchValue(e.target.value)}
- prefix={<SearchOutlined />}
- />
- </div>
- <div style={{ flex: 1, overflow: 'auto' }}>
- {treeLoading ? (
- <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
- <Spin />
- </div>
- ) : (
- <Tree
- treeData={filteredTreeData}
- titleRender={renderTreeTitle}
- selectedKeys={selectedKey ? [selectedKey] : []}
- onSelect={(selectedKeys, info: any) => {
- if (selectedKeys.length > 0) {
- const node = info.node;
- onSelect({
- key: String(selectedKeys[0]),
- type: node.type,
- data: node.type === 'category' ? statistics[node.key] : {
- total: 1,
- online: node.status === 'normal' ? 1 : 0,
- offline: node.status === 'offline' ? 1 : 0,
- error: node.status === 'error' ? 1 : 0
- }
- });
- }
- }}
- defaultExpandAll
- />
- )}
- </div>
- </Card>
- );
- };
- // 设备地图管理页面
- export const DeviceMapManagePage = () => {
- // 状态管理
- const [viewMode, setViewMode] = useState<'list' | 'map'>('map');
- const [selectedDevice, setSelectedDevice] = useState<MapViewDevice | null>(null);
- const [detailVisible, setDetailVisible] = useState(false);
- const [activeDetailTab, setActiveDetailTab] = useState('monitor');
- const [searchKeyword, setSearchKeyword] = useState('');
- const [selectedKey, setSelectedKey] = useState<string | null>(null);
- const [selectedNodeType, setSelectedNodeType] = useState<DeviceTreeNodeType | null>(null);
- const [collapsed, setCollapsed] = useState(false);
- const [statusFilter, setStatusFilter] = useState('error');
- const [mapMode] = useState<MapMode>(MapMode.ONLINE);
- const [mapCenter, setMapCenter] = useState<[number, number] | undefined>(undefined);
- const [mapZoom, setMapZoom] = useState<number>(15);
- const [loading, setLoading] = useState(false);
- const [devices, setDevices] = useState<MapViewDevice[]>([]);
- const [stats, setStats] = useState<DeviceMapStats>({ total: 0, online: 0, offline: 0, error: 0 });
- // 使用React Query加载设备数据
- const queryClient = useQueryClient();
-
- // 构建查询参数
- const buildDeviceParams = (): DeviceMapFilter => {
- let params: DeviceMapFilter = {};
-
- // 根据节点类型设置不同的查询条件
- if (selectedNodeType === DeviceTreeNodeType.CATEGORY && selectedKey) {
- params.type_code = selectedKey;
- } else if (selectedNodeType === DeviceTreeNodeType.DEVICE && selectedKey) {
- const deviceId = selectedKey.replace('device-', '');
- params.device_id = parseInt(deviceId);
- }
-
- // 状态筛选
- if (statusFilter !== 'all') {
- params.device_status = statusFilter === 'error' ? DeviceStatus.FAULT : DeviceStatus.NORMAL;
- }
-
- // 关键字搜索
- if (searchKeyword) {
- params.keyword = searchKeyword;
- }
-
- return params;
- };
- // 设备数据查询
- const { data: deviceData, isLoading } = useQuery<{
- data: MapViewDevice[];
- stats: DeviceMapStats;
- }>({
- queryKey: ['deviceMapData', searchKeyword, selectedKey, selectedNodeType, statusFilter],
- queryFn: async () => {
- const params = buildDeviceParams();
- return await MonitorAPI.getDeviceMapData(params);
- },
- refetchInterval: 30000 // 30秒自动刷新
- });
- // 处理请求成功和失败的副作用
- useEffect(() => {
- if (deviceData?.data) {
- // 如果是设备节点点击,处理设备数据
- if (selectedNodeType === DeviceTreeNodeType.DEVICE && selectedKey) {
- const deviceId = selectedKey.replace('device-', '');
- const device = deviceData.data.find((d: MapViewDevice) => d.id === parseInt(deviceId));
- if (device) {
- handleDeviceData(device);
- }
- }
- }
- }, [deviceData, selectedNodeType, selectedKey]);
- // 更新本地状态
- useEffect(() => {
- if (deviceData) {
- setDevices(deviceData.data || []);
- setStats(deviceData.stats || { total: 0, online: 0, offline: 0, error: 0 });
- }
- }, [deviceData]);
- // 手动刷新数据函数
- const handleRefresh = () => {
- queryClient.invalidateQueries({ queryKey: ['deviceMapData'] });
- };
- const handleDeviceData = (device: MapViewDevice) => {
- // 如果是地图视图,定位到设备位置
- if (viewMode === 'map' && device.longitude && device.latitude) {
- setMapCenter([device.longitude, device.latitude]);
- setMapZoom(17);
- } else if (viewMode === 'map' && (!device.longitude || !device.latitude)) {
- setSelectedDevice(device);
- setDetailVisible(true);
- setActiveDetailTab('location');
- }
- };
- const handleDeviceSelect = (device: MapViewDevice) => {
- setSelectedDevice(device);
- setDetailVisible(true);
- if (viewMode === 'map' && device.longitude && device.latitude) {
- setMapCenter([device.longitude, device.latitude]);
- setMapZoom(17);
- }
- };
- const handleTreeSelect = (node: { key: string; type: DeviceTreeNodeType; data?: any }) => {
- setSelectedKey(node.key);
- setSelectedNodeType(node.type);
-
- // 如果是列表视图切换到该分类下
- if (viewMode === 'list') {
- // 可以添加额外处理...
- }
-
- // 如果是地图视图,更新地图显示
- if (viewMode === 'map') {
- // 可以添加额外处理...
- }
- };
- const handleViewModeChange = (mode: 'list' | 'map') => {
- setViewMode(mode);
- };
- return (
- <div style={{ height: 'calc(100vh - 170px)', display: 'flex', flexDirection: 'column' }}>
- <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
- {/* 设备树侧栏 */}
- <div style={{
- width: collapsed ? 50 : 300,
- transition: 'width 0.3s',
- borderRight: '1px solid #f0f0f0',
- position: 'relative',
- height: '100%'
- }}>
- {!collapsed ? (
- <DeviceTree
- onSelect={handleTreeSelect}
- selectedKey={selectedKey}
- statusFilter={statusFilter}
- onStatusFilterChange={setStatusFilter}
- />
- ) : null}
- <Button
- type="text"
- icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
- onClick={() => setCollapsed(!collapsed)}
- style={{
- position: 'absolute',
- right: collapsed ? '50%' : -15,
- top: 20,
- transform: collapsed ? 'translateX(50%)' : 'none',
- zIndex: 2,
- backgroundColor: '#fff',
- border: '1px solid #f0f0f0',
- boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
- borderRadius: '50%',
- width: 30,
- height: 30,
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- padding: 0
- }}
- />
- </div>
-
- {/* 主内容区域 */}
- <div style={{ flex: 1, overflow: 'hidden', padding: '0 16px' }}>
- {/* 查看模式切换和刷新按钮 */}
- <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
- <Button
- onClick={handleRefresh}
- style={{ marginRight: 8 }}
- icon={<span className="icon-refresh"></span>}
- >
- 刷新
- </Button>
- <Button
- onClick={() => handleRefresh()}
- style={{ marginRight: 8 }}
- >
- 自动刷新
- </Button>
- <Radio.Group
- value={viewMode}
- onChange={e => handleViewModeChange(e.target.value)}
- buttonStyle="solid"
- >
- <Radio.Button value="list">
- <AppstoreOutlined /> 列表视图
- </Radio.Button>
- <Radio.Button value="map">
- <EnvironmentOutlined /> 地图视图
- </Radio.Button>
- </Radio.Group>
- </div>
-
- {/* 内容区 */}
- <div style={{ height: 'calc(100% - 50px)', overflow: 'hidden' }}>
- {viewMode === 'list' ? (
- <Card style={{ height: '100%', overflow: 'auto' }}>
- <List
- loading={loading}
- grid={{
- gutter: 16,
- xs: 1,
- sm: 2,
- md: 3,
- lg: 3,
- xl: 4,
- xxl: 6,
- }}
- dataSource={devices}
- renderItem={(device: MapViewDevice) => (
- <List.Item>
- <Card
- hoverable
- onClick={() => handleDeviceSelect(device)}
- cover={device.image_url ? (
- <div style={{ padding: '12px', textAlign: 'center' }}>
- <img
- src={device.image_url}
- alt={device.name}
- style={{
- height: 80,
- objectFit: 'contain',
- filter: device.isOnline === '0' ? 'grayscale(100%)' : 'none'
- }}
- />
- </div>
- ) : null}
- >
- <Card.Meta
- title={
- <div style={{ display: 'flex', justifyContent: 'space-between' }}>
- <span>{device.name}</span>
- {device.device_status === DeviceStatus.NORMAL ? (
- <Badge status="success" text="正常" />
- ) : device.device_status === DeviceStatus.FAULT ? (
- <Badge status="error" text="异常" />
- ) : device.device_status === DeviceStatus.OFFLINE ? (
- <Badge status="default" text="离线" />
- ) : (
- <Badge status="processing" text="维护" />
- )}
- </div>
- }
- description={
- <div>
- {device.longitude && device.latitude ? (
- <Tag color="blue">有位置信息</Tag>
- ) : (
- <Tag color="orange">无位置信息</Tag>
- )}
- </div>
- }
- />
- </Card>
- </List.Item>
- )}
- />
- </Card>
- ) : (
- <Card style={{ height: '100%' }} styles={{body:{ height: '100%' }}}>
- <AMap
- height="100%"
- width="100%"
- markers={devices}
- center={mapCenter}
- zoom={mapZoom}
- onMarkerClick={(markerData) => {
- // 确保markerData包含设备所需的所有属性
- const device = devices.find(d =>
- d.longitude === markerData.longitude &&
- d.latitude === markerData.latitude
- );
- if (device) {
- handleDeviceSelect(device);
- }
- }}
- mode={mapMode}
- />
- </Card>
- )}
- </div>
- </div>
- </div>
-
- {/* 设备详情抽屉 */}
- <Drawer
- title="设备详情"
- placement="right"
- onClose={() => setDetailVisible(false)}
- open={detailVisible}
- width={600}
- >
- {selectedDevice ? (
- <div>
- <Descriptions bordered column={2} style={{ marginBottom: 16 }}>
- <Descriptions.Item label="设备名称" span={2}>{selectedDevice.name}</Descriptions.Item>
- <Descriptions.Item label="设备ID">{selectedDevice.id}</Descriptions.Item>
- <Descriptions.Item label="设备状态">
- {selectedDevice.device_status === DeviceStatus.NORMAL ? (
- <Badge status="success" text="正常" />
- ) : selectedDevice.device_status === DeviceStatus.FAULT ? (
- <Badge status="error" text="异常" />
- ) : selectedDevice.device_status === DeviceStatus.OFFLINE ? (
- <Badge status="default" text="离线" />
- ) : (
- <Badge status="processing" text="维护" />
- )}
- </Descriptions.Item>
- <Descriptions.Item label="经度">{selectedDevice.longitude || '未设置'}</Descriptions.Item>
- <Descriptions.Item label="纬度">{selectedDevice.latitude || '未设置'}</Descriptions.Item>
- <Descriptions.Item label="描述" span={2}>{selectedDevice.description || '无描述'}</Descriptions.Item>
- </Descriptions>
-
- <Tabs
- activeKey={activeDetailTab}
- onChange={setActiveDetailTab}
- items={[
- {
- key: 'monitor',
- label: '监控数据',
- children: <div style={{ minHeight: 300 }}>
- <Alert message="暂无实时监控数据" type="info" />
- </div>
- },
- {
- key: 'alert',
- label: '告警记录',
- children: <div style={{ minHeight: 300 }}>
- <Alert message="暂无告警记录" type="info" />
- </div>
- },
- {
- key: 'location',
- label: '位置信息',
- children: <div style={{ minHeight: 300 }}>
- {selectedDevice.longitude && selectedDevice.latitude ? (
- <AMap
- height="300px"
- width="100%"
- center={[selectedDevice.longitude, selectedDevice.latitude]}
- zoom={15}
- markers={[selectedDevice]}
- mode={mapMode}
- />
- ) : (
- <Empty
- description="未设置位置信息"
- />
- )}
- </div>
- }
- ]}
- />
- </div>
- ) : null}
- </Drawer>
- </div>
- );
- };
|