client.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. import React, { useState, useCallback, ReactNode } from 'react';
  2. import { createRoot } from 'react-dom/client';
  3. import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
  4. import { Pie, Column, Line } from '@ant-design/plots';
  5. import { FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
  6. import {
  7. Card
  8. } from 'antd';
  9. import { AlarmDeviceData } from '../share/monitorTypes.ts';
  10. import { queryFns } from './api.ts';
  11. import { ThreeJSRoom } from './components_three.tsx';
  12. import { GlobalConfig } from "../share/types.ts";
  13. const queryClient = new QueryClient();
  14. // 声明全局配置对象类型
  15. declare global {
  16. interface Window {
  17. CONFIG?: GlobalConfig;
  18. }
  19. }
  20. interface ServerMonitorChartsProps {
  21. type?: 'pie' | 'column' | 'line' | 'pie2';
  22. }
  23. const pushStateAndTrigger = (url: string, target: string) => {
  24. window.history.pushState({}, '', url);
  25. window.dispatchEvent(new Event('popstate'));
  26. }
  27. // 统一的链接处理函数
  28. const handleNavigate = (url: string) => {
  29. // 判断是否在iframe中
  30. const isInIframe = window.self !== window.top;
  31. if (isInIframe) {
  32. pushStateAndTrigger(url, 'top');
  33. } else {
  34. window.open(url, '_blank');
  35. }
  36. };
  37. function AlarmDeviceTable() {
  38. const { data = [], isLoading } = useQuery({
  39. queryKey: ['topAlarmDevices'],
  40. queryFn: queryFns.fetchTopAlarmDevices,
  41. refetchInterval: 30000
  42. });
  43. if (isLoading) {
  44. return (
  45. <div className="flex items-center justify-center h-full">
  46. <span className="text-[#00dff9]">加载中...</span>
  47. </div>
  48. );
  49. }
  50. return (
  51. <div className="flex flex-col h-full text-sm">
  52. {/* 表头 */}
  53. <div className="grid grid-cols-[80px_100px_1fr] gap-2 px-4 py-2 text-gray-300 border-b border-[#00dff9]/20">
  54. <div className="text-center">排名</div>
  55. <div className="text-center">告警次数</div>
  56. <div className="text-left">点位名称</div>
  57. </div>
  58. {/* 表格内容 */}
  59. <div className="flex-1 overflow-y-auto">
  60. {data.map((item: AlarmDeviceData) => (
  61. <div
  62. key={item.rank}
  63. className="grid grid-cols-[80px_100px_1fr] gap-2 px-4 py-2 hover:bg-[#001529] transition-colors duration-200 border-b border-[#00dff9]/10"
  64. >
  65. <div className="text-center text-[#00dff9]">{item.rank}</div>
  66. <div className="text-center text-yellow-400">{item.alarmCount}</div>
  67. <a
  68. href="/admin/alarms"
  69. className="text-left text-[#00dff9] hover:text-white truncate"
  70. title={item.deviceName}
  71. >
  72. {item.deviceName}
  73. </a>
  74. </div>
  75. ))}
  76. {data.length === 0 && (
  77. <div className="flex items-center justify-center h-full text-gray-400">
  78. 暂无数据
  79. </div>
  80. )}
  81. </div>
  82. </div>
  83. );
  84. }
  85. function CustomCard({ title, children, className = '', bodyStyle = {}, onClick }: {
  86. title?: string;
  87. children: ReactNode;
  88. className?: string;
  89. bodyStyle?: React.CSSProperties;
  90. onClick?: () => void;
  91. }) {
  92. return (
  93. <div
  94. className={`relative bg-[#001529] border border-[#00dff9] shadow-[0_0_10px_rgba(0,223,249,0.3)] hover:shadow-[0_0_15px_rgba(0,223,249,0.4)] transition-shadow rounded-md ${className}`}
  95. onClick={onClick}
  96. >
  97. {title && (
  98. <div className="absolute -top-3 left-4 px-2 bg-[#001529] text-[#00dff9] text-sm">
  99. {title}
  100. </div>
  101. )}
  102. <div style={bodyStyle} className="p-4">
  103. {children}
  104. </div>
  105. </div>
  106. );
  107. }
  108. function MetricCards() {
  109. const { data: metrics = {
  110. totalDevices: 0,
  111. onlineDevices: 0,
  112. offlineDevices: 0,
  113. onlineRate: "0.00",
  114. riskLevel: { level: '健康', color: '#52c41a' }
  115. }, isLoading } = useQuery({
  116. queryKey: ['deviceMetrics'],
  117. queryFn: queryFns.fetchDeviceMetrics,
  118. refetchInterval: 30000,
  119. refetchIntervalInBackground: true
  120. });
  121. const metricConfigs = [
  122. {
  123. title: "设备数",
  124. value: metrics.totalDevices,
  125. link: "/admin/alarm/manage"
  126. },
  127. {
  128. title: "正常数",
  129. value: metrics.onlineDevices,
  130. link: "/admin/alarm/manage"
  131. },
  132. {
  133. title: "在线率",
  134. value: `${metrics.onlineRate}%`,
  135. link: "/admin/device/rate"
  136. },
  137. {
  138. title: "异常数",
  139. value: metrics.offlineDevices,
  140. link: "/admin/alert/manage"
  141. },
  142. {
  143. title: "风险等级",
  144. value: metrics.riskLevel.level,
  145. color: metrics.riskLevel.color,
  146. link: undefined
  147. }
  148. ];
  149. return (
  150. <>
  151. {metricConfigs.map((metric, index) => (
  152. <div
  153. key={index}
  154. onClick={() => metric.link && handleNavigate(metric.link)}
  155. className={metric.link ? "cursor-pointer" : undefined}
  156. >
  157. <Card
  158. className="bg-[#001529] border border-[#00dff9] shadow-[0_0_10px_rgba(0,223,249,0.3)] hover:shadow-[0_0_15px_rgba(0,223,249,0.4)] transition-shadow relative before:content-[''] before:absolute before:left-[3px] before:top-[3px] before:h-[calc(100%-6px)] before:w-1 before:bg-gradient-to-b before:from-yellow-500 before:via-yellow-400 before:to-transparent rounded-md"
  159. bordered={false}
  160. styles={{
  161. body: {
  162. padding: "16px",
  163. paddingLeft: "24px",
  164. display: "flex",
  165. alignItems: "center",
  166. justifyContent: "space-between",
  167. },
  168. }}
  169. >
  170. <span className="text-gray-300 text-base">{metric.title}</span>
  171. <span className="text-3xl" style={{ color: metric.color || '#00dff9' }}>
  172. {isLoading ? "-" : metric.value}
  173. </span>
  174. </Card>
  175. </div>
  176. ))}
  177. </>
  178. );
  179. }
  180. function ServerMonitorCharts({ type = 'pie' }: ServerMonitorChartsProps) {
  181. // 资产分类数据
  182. const { data: categoryData } = useQuery({
  183. queryKey: ['zichanCategory'],
  184. queryFn: queryFns.fetchCategoryData,
  185. enabled: type === 'pie'
  186. });
  187. // 在线率变化数据
  188. const { data: onlineRateData } = useQuery({
  189. queryKey: ['zichanOnlineRate'],
  190. queryFn: queryFns.fetchOnlineRateData,
  191. enabled: type === 'column'
  192. });
  193. // 资产流转状态数据
  194. const { data: stateData } = useQuery({
  195. queryKey: ['zichanState'],
  196. queryFn: queryFns.fetchStateData,
  197. enabled: type === 'pie2'
  198. });
  199. // 告警数据变化
  200. const { data: alarmData } = useQuery({
  201. queryKey: ['pingAlarm'],
  202. queryFn: queryFns.fetchAlarmData,
  203. enabled: type === 'line'
  204. });
  205. const renderChart = () => {
  206. switch (type) {
  207. case 'pie':
  208. return (
  209. <Pie
  210. data={categoryData || []}
  211. angleField="设备数"
  212. colorField="设备分类"
  213. radius={0.8}
  214. label={{
  215. position: 'outside',
  216. text: ({ 设备分类, 设备数, 百分比, percent }: { 设备分类: string, 设备数: number, 百分比: string, percent: number }) => {
  217. // 只有占比超过5%的项才显示标签
  218. if (percent < 0.05) return null;
  219. return `${设备分类}\n(${设备数})`;
  220. },
  221. style: {
  222. fill: '#fff',
  223. fontSize: 12,
  224. fontWeight: 500,
  225. },
  226. transform: [{ type: 'overlapDodgeY' }],
  227. }}
  228. theme={{
  229. colors10: ['#36cfc9', '#ff4d4f', '#ffa940', '#73d13d', '#4096ff'],
  230. }}
  231. legend={false}
  232. autoFit={true}
  233. interaction={{
  234. tooltip: {
  235. render: (_: any, { items, title }: { items: any[], title: string }) => {
  236. if (!items || items.length === 0) return '';
  237. // 获取当前选中项的数据
  238. const item = items[0];
  239. // 根据value找到对应的完整数据项
  240. const fullData = categoryData?.find(d => d['设备数'] === item.value);
  241. if (!fullData) return '';
  242. return `<div class="bg-white p-2 rounded">
  243. <div class="flex items-center">
  244. <div class="w-3 h-3 rounded-full mr-2" style="background:${item.color}"></div>
  245. <span class="font-semibold text-gray-900">${fullData['设备分类']}</span>
  246. </div>
  247. <p class="text-sm text-gray-800">数量: ${item.value}</p>
  248. <p class="text-sm text-gray-800">占比: ${fullData['百分比']}%</p>
  249. </div>`;
  250. }
  251. }
  252. }}
  253. />
  254. );
  255. case 'column':
  256. return (
  257. <Column
  258. data={onlineRateData || []}
  259. xField="time_interval"
  260. yField="total_devices"
  261. color="#36cfc9"
  262. label={{
  263. position: 'top',
  264. style: {
  265. fill: '#fff',
  266. },
  267. text: (items: any) => {
  268. let content = items['time_interval'];
  269. content += `\n(${(items['total_devices'])})`;
  270. return content;
  271. },
  272. }}
  273. xAxis={{
  274. label: {
  275. style: {
  276. fill: '#fff',
  277. },
  278. },
  279. }}
  280. yAxis={{
  281. label: {
  282. style: {
  283. fill: '#fff',
  284. },
  285. },
  286. }}
  287. autoFit={true}
  288. interaction={{
  289. tooltip:false
  290. }}
  291. />
  292. );
  293. case 'line':
  294. return (
  295. <Line
  296. data={alarmData || []}
  297. xField="time_interval"
  298. yField="total_devices"
  299. smooth={true}
  300. color="#36cfc9"
  301. label={{
  302. position: 'top',
  303. style: {
  304. fill: '#fff',
  305. fontSize: 12,
  306. fontWeight: 500,
  307. },
  308. text: (items: any) => {
  309. const value = items['total_devices'];
  310. // if (value === 0) return null;
  311. // const maxValue = Math.max(...(alarmData || []).map(item => item.total_devices));
  312. // if (value < maxValue * 0.3 && alarmData && alarmData.length > 8) return null;
  313. return `${items['time_interval']}\n(${value})`;
  314. },
  315. transform: [{ type: 'overlapDodgeY' }],
  316. }}
  317. xAxis={{
  318. label: {
  319. style: {
  320. fill: '#fff',
  321. },
  322. autoHide: true,
  323. autoRotate: true,
  324. },
  325. }}
  326. yAxis={{
  327. label: {
  328. style: {
  329. fill: '#fff',
  330. },
  331. },
  332. }}
  333. autoFit={true}
  334. interaction={{
  335. tooltip: {
  336. render: (_: any, { items, title }: { items: any[], title: string }) => {
  337. if (!items || items.length === 0) return '';
  338. // 获取当前选中项的数据
  339. const item = items[0];
  340. // 根据value找到对应的完整数据项
  341. const fullData = alarmData?.find(d => d.total_devices === item.value);
  342. if (!fullData) return '';
  343. return `<div class="bg-white p-2 rounded">
  344. <div class="flex items-center">
  345. <div class="w-3 h-3 rounded-full mr-2" style="background:${item.color}"></div>
  346. <span class="font-semibold text-gray-900">${fullData.time_interval}</span>
  347. </div>
  348. <p class="text-sm text-gray-800">数量: ${item.value}</p>
  349. </div>`;
  350. }
  351. }
  352. }}
  353. />
  354. );
  355. case 'pie2':
  356. return (
  357. <Pie
  358. data={stateData || []}
  359. angleField="设备数"
  360. colorField="资产流转"
  361. radius={0.9}
  362. innerRadius={0.8}
  363. label={{
  364. position: 'outside',
  365. text: ({ 资产流转, 设备数, 百分比, percent }: { 资产流转: string, 设备数: number, 百分比: string, percent: number }) => {
  366. // 只有占比超过5%的项才显示标签
  367. if (percent < 0.05) return null;
  368. return `${资产流转}\n(${设备数})`;
  369. },
  370. style: {
  371. fill: '#fff',
  372. fontSize: 12,
  373. fontWeight: 500,
  374. },
  375. transform: [{ type: 'overlapDodgeY' }],
  376. }}
  377. theme={{
  378. colors10: ['#36cfc9', '#ff4d4f', '#ffa940', '#73d13d', '#4096ff'],
  379. }}
  380. legend={{
  381. color: {
  382. itemLabelFill: '#fff',
  383. }
  384. }}
  385. autoFit={true}
  386. interaction={{
  387. tooltip: {
  388. render: (_: any, { items, title }: { items: any[], title: string }) => {
  389. if (!items || items.length === 0) return '';
  390. // 获取当前选中项的数据
  391. const item = items[0];
  392. // 根据value找到对应的完整数据项
  393. const fullData = stateData?.find(d => d['设备数'] === item.value);
  394. if (!fullData) return '';
  395. return `<div class="bg-white p-2 rounded">
  396. <div class="flex items-center">
  397. <div class="w-3 h-3 rounded-full mr-2" style="background:${item.color}"></div>
  398. <span class="font-semibold text-gray-900">${fullData['资产流转']}</span>
  399. </div>
  400. <p class="text-sm text-gray-800">数量: ${item.value}</p>
  401. <p class="text-sm text-gray-800">占比: ${fullData['百分比']}%</p>
  402. </div>`;
  403. }
  404. }
  405. }}
  406. />
  407. );
  408. }
  409. };
  410. return (
  411. <div className="w-full h-full">
  412. {renderChart()}
  413. </div>
  414. );
  415. }
  416. function PageTitle() {
  417. return (
  418. <div className="relative w-full">
  419. {/* 背景图片 */}
  420. <div className="w-full h-[60px]">
  421. <img
  422. src="/client/big/title-bg.png"
  423. alt="title background"
  424. className="w-full h-full object-fill"
  425. />
  426. </div>
  427. </div>
  428. );
  429. }
  430. function DataCenter() {
  431. const [isFullscreen, setIsFullscreen] = useState(false);
  432. const toggleFullscreen = useCallback(() => {
  433. if (!document.fullscreenElement) {
  434. document.documentElement.requestFullscreen();
  435. setIsFullscreen(true);
  436. } else {
  437. document.exitFullscreen().then(() => {
  438. setIsFullscreen(false);
  439. window.location.reload();
  440. });
  441. }
  442. }, []);
  443. return (
  444. <div className="h-screen w-full bg-[#000C17] text-white overflow-hidden">
  445. {/* 顶部标题区域 */}
  446. <div className="relative">
  447. <PageTitle />
  448. {/* 全屏切换按钮 */}
  449. <button
  450. type="button"
  451. onClick={toggleFullscreen}
  452. className="absolute right-4 top-4 text-[#00dff9] hover:text-white bg-transparent border-none cursor-pointer p-2"
  453. >
  454. {isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
  455. </button>
  456. </div>
  457. {/* 主要内容区域 */}
  458. <div className="p-4">
  459. {/* 顶部指标卡片 */}
  460. <div className="grid grid-cols-5 gap-3 mb-3">
  461. <MetricCards />
  462. </div>
  463. <div className="grid grid-cols-[25%_75%] gap-3 h-[calc(100vh-160px)] pb-4 pr-4">
  464. {/* 左侧图表区域 */}
  465. <div>
  466. <div className="grid grid-rows-[1.2fr_1fr_1fr] gap-3 h-full">
  467. {/* 饼图 */}
  468. <CustomCard
  469. title="资产分类"
  470. bodyStyle={{
  471. height: '100%',
  472. overflow: 'hidden'
  473. }}
  474. className="cursor-pointer hover:shadow-lg transition-shadow"
  475. onClick={() => handleNavigate('/admin/device/type')}
  476. >
  477. <div className="h-full w-full">
  478. <ServerMonitorCharts type="pie" />
  479. </div>
  480. </CustomCard>
  481. {/* 柱状图 */}
  482. <CustomCard
  483. title="在线设备数量"
  484. bodyStyle={{
  485. height: '100%',
  486. overflow: 'hidden'
  487. }}
  488. className="cursor-pointer hover:shadow-lg transition-shadow"
  489. onClick={() => handleNavigate('/admin/device/rate')}
  490. >
  491. <div className="h-full w-full">
  492. <ServerMonitorCharts type="column" />
  493. </div>
  494. </CustomCard>
  495. {/* 饼图2 */}
  496. <CustomCard
  497. title="资产流转状态汇总"
  498. bodyStyle={{
  499. height: '100%',
  500. overflow: 'hidden'
  501. }}
  502. className="cursor-pointer hover:shadow-lg transition-shadow"
  503. onClick={() => handleNavigate('/admin/asset/transfer/chart')}
  504. >
  505. <div className="h-full w-full">
  506. <ServerMonitorCharts type="pie2" />
  507. </div>
  508. </CustomCard>
  509. </div>
  510. </div>
  511. {/* 右侧区域 */}
  512. <div className="grid grid-rows-[2fr_1fr] gap-3 h-full">
  513. {/* 中间3D机房区域 */}
  514. <CustomCard
  515. title="机房可视化"
  516. bodyStyle={{
  517. height: '100%',
  518. display: 'flex',
  519. flexDirection: 'column'
  520. }}
  521. >
  522. <div className="flex-1 min-h-0" >
  523. <ThreeJSRoom />
  524. </div>
  525. </CustomCard>
  526. {/* 底部区域分为两列 */}
  527. <div className="grid grid-cols-2 gap-3">
  528. {/* 左侧告警曲线图 */}
  529. <CustomCard
  530. title="近期告警数据变化"
  531. bodyStyle={{
  532. height: '100%',
  533. overflow: 'hidden'
  534. }}
  535. className="cursor-pointer hover:shadow-lg transition-shadow"
  536. onClick={() => handleNavigate('/admin/alarm/trend')}
  537. >
  538. <div className="h-full w-full">
  539. <ServerMonitorCharts type="line" />
  540. </div>
  541. </CustomCard>
  542. {/* 右侧告警设备表格 */}
  543. <CustomCard
  544. title="告警靠前设备"
  545. bodyStyle={{
  546. height: 'calc(100vh - 700px)',
  547. overflow: 'hidden'
  548. }}
  549. className="cursor-pointer hover:shadow-lg transition-shadow"
  550. onClick={() => handleNavigate('/admin/alert/manage')}
  551. >
  552. <div className="h-full w-full">
  553. <AlarmDeviceTable />
  554. </div>
  555. </CustomCard>
  556. </div>
  557. </div>
  558. </div>
  559. </div>
  560. </div>
  561. );
  562. }
  563. // 渲染应用
  564. const root = createRoot(document.getElementById('root') as HTMLElement);
  565. root.render(
  566. <QueryClientProvider client={queryClient}>
  567. <DataCenter />
  568. </QueryClientProvider>
  569. );