pages_device_map.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. import React, { useState, useEffect } from 'react';
  2. import {
  3. Button, Input, message,
  4. Card, Spin, Badge, Descriptions,
  5. Tag, Radio, Tabs, List, Alert, Empty, Drawer,
  6. Tree
  7. } from 'antd';
  8. import {
  9. MenuFoldOutlined,
  10. MenuUnfoldOutlined,
  11. AppstoreOutlined,
  12. EnvironmentOutlined,
  13. SearchOutlined
  14. } from '@ant-design/icons';
  15. import {
  16. useQuery,
  17. useQueryClient
  18. } from '@tanstack/react-query';
  19. import 'dayjs/locale/zh-cn';
  20. import AMap from './components_amap.tsx'; // 导入地图组件
  21. // 从share/types.ts导入所有类型,包括MapMode
  22. import type {
  23. MapViewDevice, DeviceMapFilter, DeviceMapStats,
  24. DeviceTreeNode, DeviceTreeStats
  25. } from '../share/monitorTypes.ts';
  26. import {
  27. DeviceStatus, DeviceTreeNodeType
  28. } from '../share/monitorTypes.ts';
  29. import { MonitorAPI} from './api/index.ts';
  30. import { MapMode } from "../share/types.ts";
  31. // 设备树组件接口定义
  32. interface DeviceTreeProps {
  33. onSelect: (node: {
  34. key: string;
  35. type: DeviceTreeNodeType;
  36. data?: {
  37. total: number;
  38. online: number;
  39. offline: number;
  40. error: number;
  41. }
  42. }) => void;
  43. selectedKey: string | null;
  44. statusFilter: string;
  45. onStatusFilterChange: (status: string) => void;
  46. }
  47. // 设备树组件
  48. const DeviceTree = ({ onSelect, selectedKey, statusFilter, onStatusFilterChange }: DeviceTreeProps) => {
  49. const [searchValue, setSearchValue] = useState('');
  50. // 使用React Query获取设备树数据
  51. const { data: treeData = [], isLoading: treeLoading } = useQuery<DeviceTreeNode[]>({
  52. queryKey: ['deviceTree', statusFilter, searchValue],
  53. queryFn: async () => {
  54. try {
  55. // 构建API参数
  56. const params: {
  57. status?: string,
  58. keyword?: string
  59. } = {};
  60. // 添加状态过滤
  61. if (statusFilter !== 'all') {
  62. params.status = statusFilter;
  63. }
  64. // 添加关键词搜索
  65. if (searchValue) {
  66. params.keyword = searchValue;
  67. }
  68. const response = await MonitorAPI.getDeviceTree(params);
  69. return response.data || [];
  70. } catch (error) {
  71. console.error("获取设备树失败:", error);
  72. message.error("获取设备树失败");
  73. return [];
  74. }
  75. },
  76. refetchInterval: 30000 // 30秒刷新一次
  77. });
  78. // 使用React Query获取设备统计数据
  79. const { data: statistics = {} as DeviceTreeStats } = useQuery<DeviceTreeStats>({
  80. queryKey: ['deviceTreeStats'],
  81. queryFn: async () => {
  82. try {
  83. const response = await MonitorAPI.getDeviceTreeStats();
  84. return response.data || {};
  85. } catch (error) {
  86. console.error("获取设备统计失败:", error);
  87. message.error("获取设备统计失败");
  88. return {};
  89. }
  90. },
  91. refetchInterval: 30000 // 30秒刷新一次
  92. });
  93. // 直接使用从后端获取的已过滤树数据
  94. const filteredTreeData = treeData;
  95. // 处理树节点标题渲染
  96. const renderTreeTitle = (node: DeviceTreeNode) => {
  97. const stats = statistics[node.key] || {
  98. total: 0,
  99. online: 0,
  100. offline: 0,
  101. error: 0
  102. };
  103. return (
  104. <div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', paddingRight: 8 }}>
  105. <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  106. {node.icon && (
  107. <img
  108. src={node.icon}
  109. alt={node.title}
  110. style={{
  111. width: 16,
  112. height: 16,
  113. filter: node.status === 'offline' ? 'grayscale(100%)' : 'none'
  114. }}
  115. />
  116. )}
  117. <span style={{ color: node.status === 'offline' ? '#999' : 'inherit' }}>
  118. {node.title}
  119. </span>
  120. </span>
  121. {node.type === 'category' ? (
  122. <div style={{ display: 'flex', gap: 8, fontSize: 12 }}>
  123. {stats.error > 0 && (
  124. <span style={{ color: '#f5222d' }}>{stats.error}</span>
  125. )}
  126. {stats.offline > 0 && (
  127. <span style={{ color: '#999' }}>{stats.offline}</span>
  128. )}
  129. {stats.online > 0 && (
  130. <span style={{ color: '#52c41a' }}>{stats.online}</span>
  131. )}
  132. <span style={{ color: '#ccc' }}>/{stats.total}</span>
  133. </div>
  134. ) : (
  135. <div style={{ display: 'flex', gap: 8, fontSize: 12 }}>
  136. {node.status === 'error' && (
  137. <span style={{ color: '#f5222d' }}>异常</span>
  138. )}
  139. {node.status === 'warning' && (
  140. <span style={{ color: '#faad14' }}>警告</span>
  141. )}
  142. {node.status === 'offline' && (
  143. <span style={{ color: '#999' }}>离线</span>
  144. )}
  145. {node.status === 'normal' && (
  146. <span style={{ color: '#52c41a' }}>正常</span>
  147. )}
  148. </div>
  149. )}
  150. </div>
  151. );
  152. };
  153. return (
  154. <Card
  155. style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
  156. variant="borderless"
  157. styles={{body:{ height: '100%', display: 'flex', flexDirection: 'column' }}}
  158. >
  159. <div style={{ marginBottom: 16 }}>
  160. <Radio.Group
  161. value={statusFilter}
  162. onChange={e => onStatusFilterChange(e.target.value)}
  163. style={{ width: '100%', display: 'flex', marginBottom: 12 }}
  164. size="small"
  165. >
  166. <Radio.Button value="error" style={{ flex: 1, textAlign: 'center' }}>
  167. 异常
  168. </Radio.Button>
  169. <Radio.Button value="normal" style={{ flex: 1, textAlign: 'center' }}>
  170. 正常
  171. </Radio.Button>
  172. <Radio.Button value="all" style={{ flex: 1, textAlign: 'center' }}>
  173. 全部
  174. </Radio.Button>
  175. </Radio.Group>
  176. <Input.Search
  177. placeholder="搜索设备分类或设备"
  178. allowClear
  179. onChange={e => setSearchValue(e.target.value)}
  180. prefix={<SearchOutlined />}
  181. />
  182. </div>
  183. <div style={{ flex: 1, overflow: 'auto' }}>
  184. {treeLoading ? (
  185. <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
  186. <Spin />
  187. </div>
  188. ) : (
  189. <Tree
  190. treeData={filteredTreeData}
  191. titleRender={renderTreeTitle}
  192. selectedKeys={selectedKey ? [selectedKey] : []}
  193. onSelect={(selectedKeys, info: any) => {
  194. if (selectedKeys.length > 0) {
  195. const node = info.node;
  196. onSelect({
  197. key: String(selectedKeys[0]),
  198. type: node.type,
  199. data: node.type === 'category' ? statistics[node.key] : {
  200. total: 1,
  201. online: node.status === 'normal' ? 1 : 0,
  202. offline: node.status === 'offline' ? 1 : 0,
  203. error: node.status === 'error' ? 1 : 0
  204. }
  205. });
  206. }
  207. }}
  208. defaultExpandAll
  209. />
  210. )}
  211. </div>
  212. </Card>
  213. );
  214. };
  215. // 设备地图管理页面
  216. export const DeviceMapManagePage = () => {
  217. // 状态管理
  218. const [viewMode, setViewMode] = useState<'list' | 'map'>('map');
  219. const [selectedDevice, setSelectedDevice] = useState<MapViewDevice | null>(null);
  220. const [detailVisible, setDetailVisible] = useState(false);
  221. const [activeDetailTab, setActiveDetailTab] = useState('monitor');
  222. const [searchKeyword, setSearchKeyword] = useState('');
  223. const [selectedKey, setSelectedKey] = useState<string | null>(null);
  224. const [selectedNodeType, setSelectedNodeType] = useState<DeviceTreeNodeType | null>(null);
  225. const [collapsed, setCollapsed] = useState(false);
  226. const [statusFilter, setStatusFilter] = useState('error');
  227. const [mapMode] = useState<MapMode>(MapMode.ONLINE);
  228. const [mapCenter, setMapCenter] = useState<[number, number] | undefined>(undefined);
  229. const [mapZoom, setMapZoom] = useState<number>(15);
  230. const [loading, setLoading] = useState(false);
  231. const [devices, setDevices] = useState<MapViewDevice[]>([]);
  232. const [stats, setStats] = useState<DeviceMapStats>({ total: 0, online: 0, offline: 0, error: 0 });
  233. // 使用React Query加载设备数据
  234. const queryClient = useQueryClient();
  235. // 构建查询参数
  236. const buildDeviceParams = (): DeviceMapFilter => {
  237. let params: DeviceMapFilter = {};
  238. // 根据节点类型设置不同的查询条件
  239. if (selectedNodeType === DeviceTreeNodeType.CATEGORY && selectedKey) {
  240. params.type_code = selectedKey;
  241. } else if (selectedNodeType === DeviceTreeNodeType.DEVICE && selectedKey) {
  242. const deviceId = selectedKey.replace('device-', '');
  243. params.device_id = parseInt(deviceId);
  244. }
  245. // 状态筛选
  246. if (statusFilter !== 'all') {
  247. params.device_status = statusFilter === 'error' ? DeviceStatus.FAULT : DeviceStatus.NORMAL;
  248. }
  249. // 关键字搜索
  250. if (searchKeyword) {
  251. params.keyword = searchKeyword;
  252. }
  253. return params;
  254. };
  255. // 设备数据查询
  256. const { data: deviceData, isLoading } = useQuery<{
  257. data: MapViewDevice[];
  258. stats: DeviceMapStats;
  259. }>({
  260. queryKey: ['deviceMapData', searchKeyword, selectedKey, selectedNodeType, statusFilter],
  261. queryFn: async () => {
  262. const params = buildDeviceParams();
  263. return await MonitorAPI.getDeviceMapData(params);
  264. },
  265. refetchInterval: 30000 // 30秒自动刷新
  266. });
  267. // 处理请求成功和失败的副作用
  268. useEffect(() => {
  269. if (deviceData?.data) {
  270. // 如果是设备节点点击,处理设备数据
  271. if (selectedNodeType === DeviceTreeNodeType.DEVICE && selectedKey) {
  272. const deviceId = selectedKey.replace('device-', '');
  273. const device = deviceData.data.find((d: MapViewDevice) => d.id === parseInt(deviceId));
  274. if (device) {
  275. handleDeviceData(device);
  276. }
  277. }
  278. }
  279. }, [deviceData, selectedNodeType, selectedKey]);
  280. // 更新本地状态
  281. useEffect(() => {
  282. if (deviceData) {
  283. setDevices(deviceData.data || []);
  284. setStats(deviceData.stats || { total: 0, online: 0, offline: 0, error: 0 });
  285. }
  286. }, [deviceData]);
  287. // 手动刷新数据函数
  288. const handleRefresh = () => {
  289. queryClient.invalidateQueries({ queryKey: ['deviceMapData'] });
  290. };
  291. const handleDeviceData = (device: MapViewDevice) => {
  292. // 如果是地图视图,定位到设备位置
  293. if (viewMode === 'map' && device.longitude && device.latitude) {
  294. setMapCenter([device.longitude, device.latitude]);
  295. setMapZoom(17);
  296. } else if (viewMode === 'map' && (!device.longitude || !device.latitude)) {
  297. setSelectedDevice(device);
  298. setDetailVisible(true);
  299. setActiveDetailTab('location');
  300. }
  301. };
  302. const handleDeviceSelect = (device: MapViewDevice) => {
  303. setSelectedDevice(device);
  304. setDetailVisible(true);
  305. if (viewMode === 'map' && device.longitude && device.latitude) {
  306. setMapCenter([device.longitude, device.latitude]);
  307. setMapZoom(17);
  308. }
  309. };
  310. const handleTreeSelect = (node: { key: string; type: DeviceTreeNodeType; data?: any }) => {
  311. setSelectedKey(node.key);
  312. setSelectedNodeType(node.type);
  313. // 如果是列表视图切换到该分类下
  314. if (viewMode === 'list') {
  315. // 可以添加额外处理...
  316. }
  317. // 如果是地图视图,更新地图显示
  318. if (viewMode === 'map') {
  319. // 可以添加额外处理...
  320. }
  321. };
  322. const handleViewModeChange = (mode: 'list' | 'map') => {
  323. setViewMode(mode);
  324. };
  325. return (
  326. <div style={{ height: 'calc(100vh - 170px)', display: 'flex', flexDirection: 'column' }}>
  327. <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
  328. {/* 设备树侧栏 */}
  329. <div style={{
  330. width: collapsed ? 50 : 300,
  331. transition: 'width 0.3s',
  332. borderRight: '1px solid #f0f0f0',
  333. position: 'relative',
  334. height: '100%'
  335. }}>
  336. {!collapsed ? (
  337. <DeviceTree
  338. onSelect={handleTreeSelect}
  339. selectedKey={selectedKey}
  340. statusFilter={statusFilter}
  341. onStatusFilterChange={setStatusFilter}
  342. />
  343. ) : null}
  344. <Button
  345. type="text"
  346. icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
  347. onClick={() => setCollapsed(!collapsed)}
  348. style={{
  349. position: 'absolute',
  350. right: collapsed ? '50%' : -15,
  351. top: 20,
  352. transform: collapsed ? 'translateX(50%)' : 'none',
  353. zIndex: 2,
  354. backgroundColor: '#fff',
  355. border: '1px solid #f0f0f0',
  356. boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
  357. borderRadius: '50%',
  358. width: 30,
  359. height: 30,
  360. display: 'flex',
  361. justifyContent: 'center',
  362. alignItems: 'center',
  363. padding: 0
  364. }}
  365. />
  366. </div>
  367. {/* 主内容区域 */}
  368. <div style={{ flex: 1, overflow: 'hidden', padding: '0 16px' }}>
  369. {/* 查看模式切换和刷新按钮 */}
  370. <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
  371. <Button
  372. onClick={handleRefresh}
  373. style={{ marginRight: 8 }}
  374. icon={<span className="icon-refresh"></span>}
  375. >
  376. 刷新
  377. </Button>
  378. <Button
  379. onClick={() => handleRefresh()}
  380. style={{ marginRight: 8 }}
  381. >
  382. 自动刷新
  383. </Button>
  384. <Radio.Group
  385. value={viewMode}
  386. onChange={e => handleViewModeChange(e.target.value)}
  387. buttonStyle="solid"
  388. >
  389. <Radio.Button value="list">
  390. <AppstoreOutlined /> 列表视图
  391. </Radio.Button>
  392. <Radio.Button value="map">
  393. <EnvironmentOutlined /> 地图视图
  394. </Radio.Button>
  395. </Radio.Group>
  396. </div>
  397. {/* 内容区 */}
  398. <div style={{ height: 'calc(100% - 50px)', overflow: 'hidden' }}>
  399. {viewMode === 'list' ? (
  400. <Card style={{ height: '100%', overflow: 'auto' }}>
  401. <List
  402. loading={loading}
  403. grid={{
  404. gutter: 16,
  405. xs: 1,
  406. sm: 2,
  407. md: 3,
  408. lg: 3,
  409. xl: 4,
  410. xxl: 6,
  411. }}
  412. dataSource={devices}
  413. renderItem={(device: MapViewDevice) => (
  414. <List.Item>
  415. <Card
  416. hoverable
  417. onClick={() => handleDeviceSelect(device)}
  418. cover={device.image_url ? (
  419. <div style={{ padding: '12px', textAlign: 'center' }}>
  420. <img
  421. src={device.image_url}
  422. alt={device.name}
  423. style={{
  424. height: 80,
  425. objectFit: 'contain',
  426. filter: device.isOnline === '0' ? 'grayscale(100%)' : 'none'
  427. }}
  428. />
  429. </div>
  430. ) : null}
  431. >
  432. <Card.Meta
  433. title={
  434. <div style={{ display: 'flex', justifyContent: 'space-between' }}>
  435. <span>{device.name}</span>
  436. {device.device_status === DeviceStatus.NORMAL ? (
  437. <Badge status="success" text="正常" />
  438. ) : device.device_status === DeviceStatus.FAULT ? (
  439. <Badge status="error" text="异常" />
  440. ) : device.device_status === DeviceStatus.OFFLINE ? (
  441. <Badge status="default" text="离线" />
  442. ) : (
  443. <Badge status="processing" text="维护" />
  444. )}
  445. </div>
  446. }
  447. description={
  448. <div>
  449. {device.longitude && device.latitude ? (
  450. <Tag color="blue">有位置信息</Tag>
  451. ) : (
  452. <Tag color="orange">无位置信息</Tag>
  453. )}
  454. </div>
  455. }
  456. />
  457. </Card>
  458. </List.Item>
  459. )}
  460. />
  461. </Card>
  462. ) : (
  463. <Card style={{ height: '100%' }} styles={{body:{ height: '100%' }}}>
  464. <AMap
  465. height="100%"
  466. width="100%"
  467. markers={devices}
  468. center={mapCenter}
  469. zoom={mapZoom}
  470. onMarkerClick={(markerData) => {
  471. // 确保markerData包含设备所需的所有属性
  472. const device = devices.find(d =>
  473. d.longitude === markerData.longitude &&
  474. d.latitude === markerData.latitude
  475. );
  476. if (device) {
  477. handleDeviceSelect(device);
  478. }
  479. }}
  480. mode={mapMode}
  481. />
  482. </Card>
  483. )}
  484. </div>
  485. </div>
  486. </div>
  487. {/* 设备详情抽屉 */}
  488. <Drawer
  489. title="设备详情"
  490. placement="right"
  491. onClose={() => setDetailVisible(false)}
  492. open={detailVisible}
  493. width={600}
  494. >
  495. {selectedDevice ? (
  496. <div>
  497. <Descriptions bordered column={2} style={{ marginBottom: 16 }}>
  498. <Descriptions.Item label="设备名称" span={2}>{selectedDevice.name}</Descriptions.Item>
  499. <Descriptions.Item label="设备ID">{selectedDevice.id}</Descriptions.Item>
  500. <Descriptions.Item label="设备状态">
  501. {selectedDevice.device_status === DeviceStatus.NORMAL ? (
  502. <Badge status="success" text="正常" />
  503. ) : selectedDevice.device_status === DeviceStatus.FAULT ? (
  504. <Badge status="error" text="异常" />
  505. ) : selectedDevice.device_status === DeviceStatus.OFFLINE ? (
  506. <Badge status="default" text="离线" />
  507. ) : (
  508. <Badge status="processing" text="维护" />
  509. )}
  510. </Descriptions.Item>
  511. <Descriptions.Item label="经度">{selectedDevice.longitude || '未设置'}</Descriptions.Item>
  512. <Descriptions.Item label="纬度">{selectedDevice.latitude || '未设置'}</Descriptions.Item>
  513. <Descriptions.Item label="描述" span={2}>{selectedDevice.description || '无描述'}</Descriptions.Item>
  514. </Descriptions>
  515. <Tabs
  516. activeKey={activeDetailTab}
  517. onChange={setActiveDetailTab}
  518. items={[
  519. {
  520. key: 'monitor',
  521. label: '监控数据',
  522. children: <div style={{ minHeight: 300 }}>
  523. <Alert message="暂无实时监控数据" type="info" />
  524. </div>
  525. },
  526. {
  527. key: 'alert',
  528. label: '告警记录',
  529. children: <div style={{ minHeight: 300 }}>
  530. <Alert message="暂无告警记录" type="info" />
  531. </div>
  532. },
  533. {
  534. key: 'location',
  535. label: '位置信息',
  536. children: <div style={{ minHeight: 300 }}>
  537. {selectedDevice.longitude && selectedDevice.latitude ? (
  538. <AMap
  539. height="300px"
  540. width="100%"
  541. center={[selectedDevice.longitude, selectedDevice.latitude]}
  542. zoom={15}
  543. markers={[selectedDevice]}
  544. mode={mapMode}
  545. />
  546. ) : (
  547. <Empty
  548. description="未设置位置信息"
  549. />
  550. )}
  551. </div>
  552. }
  553. ]}
  554. />
  555. </div>
  556. ) : null}
  557. </Drawer>
  558. </div>
  559. );
  560. };