index.js 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477
  1. import React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react';
  2. import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
  3. import { useNavigate } from 'react-router-dom';
  4. import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle } from 'lucide-react';
  5. import {
  6. Card,
  7. Form,
  8. Spin,
  9. IconButton,
  10. Modal,
  11. Avatar,
  12. Tabs,
  13. TabPane,
  14. Empty,
  15. Tag,
  16. Timeline,
  17. Collapse,
  18. Progress,
  19. Divider
  20. } from '@douyinfe/semi-ui';
  21. import {
  22. IconRefresh,
  23. IconSearch,
  24. IconMoneyExchangeStroked,
  25. IconHistogram,
  26. IconRotate,
  27. IconCoinMoneyStroked,
  28. IconTextStroked,
  29. IconPulse,
  30. IconStopwatchStroked,
  31. IconTypograph,
  32. IconPieChart2Stroked,
  33. IconPlus,
  34. IconMinus
  35. } from '@douyinfe/semi-icons';
  36. import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
  37. import { VChart } from '@visactor/react-vchart';
  38. import {
  39. API,
  40. isAdmin,
  41. isMobile,
  42. showError,
  43. timestamp2string,
  44. timestamp2string1,
  45. getQuotaWithUnit,
  46. modelColorMap,
  47. renderNumber,
  48. renderQuota,
  49. modelToColor,
  50. copy,
  51. showSuccess,
  52. getRelativeTime
  53. } from '../../helpers';
  54. import { UserContext } from '../../context/User/index.js';
  55. import { StatusContext } from '../../context/Status/index.js';
  56. import { useTranslation } from 'react-i18next';
  57. const Detail = (props) => {
  58. // ========== Hooks - Context ==========
  59. const [userState, userDispatch] = useContext(UserContext);
  60. const [statusState, statusDispatch] = useContext(StatusContext);
  61. // ========== Hooks - Navigation & Translation ==========
  62. const { t } = useTranslation();
  63. const navigate = useNavigate();
  64. // ========== Hooks - Refs ==========
  65. const formRef = useRef();
  66. const initialized = useRef(false);
  67. const apiScrollRef = useRef(null);
  68. // ========== Constants & Shared Configurations ==========
  69. const CHART_CONFIG = { mode: 'desktop-browser' };
  70. const CARD_PROPS = {
  71. shadows: 'always',
  72. bordered: false,
  73. headerLine: true
  74. };
  75. const FORM_FIELD_PROPS = {
  76. className: "w-full mb-2 !rounded-lg",
  77. size: 'large'
  78. };
  79. const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
  80. const FLEX_CENTER_GAP2 = "flex items-center gap-2";
  81. const ILLUSTRATION_SIZE = { width: 96, height: 96 };
  82. // ========== Constants ==========
  83. let now = new Date();
  84. const isAdminUser = isAdmin();
  85. // ========== Panel enable flags ==========
  86. const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
  87. const announcementsEnabled = statusState?.status?.announcements_enabled ?? true;
  88. const faqEnabled = statusState?.status?.faq_enabled ?? true;
  89. const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
  90. const hasApiInfoPanel = apiInfoEnabled;
  91. const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
  92. // ========== Helper Functions ==========
  93. const getDefaultTime = useCallback(() => {
  94. return localStorage.getItem('data_export_default_time') || 'hour';
  95. }, []);
  96. const getTimeInterval = useCallback((timeType, isSeconds = false) => {
  97. const intervals = {
  98. hour: isSeconds ? 3600 : 60,
  99. day: isSeconds ? 86400 : 1440,
  100. week: isSeconds ? 604800 : 10080
  101. };
  102. return intervals[timeType] || intervals.hour;
  103. }, []);
  104. const getInitialTimestamp = useCallback(() => {
  105. const defaultTime = getDefaultTime();
  106. const now = new Date().getTime() / 1000;
  107. switch (defaultTime) {
  108. case 'hour':
  109. return timestamp2string(now - 86400);
  110. case 'week':
  111. return timestamp2string(now - 86400 * 30);
  112. default:
  113. return timestamp2string(now - 86400 * 7);
  114. }
  115. }, [getDefaultTime]);
  116. const updateMapValue = useCallback((map, key, value) => {
  117. if (!map.has(key)) {
  118. map.set(key, 0);
  119. }
  120. map.set(key, map.get(key) + value);
  121. }, []);
  122. const initializeMaps = useCallback((key, ...maps) => {
  123. maps.forEach(map => {
  124. if (!map.has(key)) {
  125. map.set(key, 0);
  126. }
  127. });
  128. }, []);
  129. const updateChartSpec = useCallback((setterFunc, newData, subtitle, newColors, dataId) => {
  130. setterFunc(prev => ({
  131. ...prev,
  132. data: [{ id: dataId, values: newData }],
  133. title: {
  134. ...prev.title,
  135. subtext: subtitle,
  136. },
  137. color: {
  138. specified: newColors,
  139. },
  140. }));
  141. }, []);
  142. const createSectionTitle = useCallback((Icon, text) => (
  143. <div className={FLEX_CENTER_GAP2}>
  144. <Icon size={16} />
  145. {text}
  146. </div>
  147. ), []);
  148. const createFormField = useCallback((Component, props) => (
  149. <Component {...FORM_FIELD_PROPS} {...props} />
  150. ), []);
  151. // ========== Time Options ==========
  152. const timeOptions = useMemo(() => [
  153. { label: t('小时'), value: 'hour' },
  154. { label: t('天'), value: 'day' },
  155. { label: t('周'), value: 'week' },
  156. ], [t]);
  157. // ========== Hooks - State ==========
  158. const [inputs, setInputs] = useState({
  159. username: '',
  160. token_name: '',
  161. model_name: '',
  162. start_timestamp: getInitialTimestamp(),
  163. end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
  164. channel: '',
  165. data_export_default_time: '',
  166. });
  167. const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime());
  168. const [loading, setLoading] = useState(false);
  169. const [quotaData, setQuotaData] = useState([]);
  170. const [consumeQuota, setConsumeQuota] = useState(0);
  171. const [consumeTokens, setConsumeTokens] = useState(0);
  172. const [times, setTimes] = useState(0);
  173. const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
  174. const [lineData, setLineData] = useState([]);
  175. const [modelColors, setModelColors] = useState({});
  176. const [activeChartTab, setActiveChartTab] = useState('1');
  177. const [showApiScrollHint, setShowApiScrollHint] = useState(false);
  178. const [searchModalVisible, setSearchModalVisible] = useState(false);
  179. const [trendData, setTrendData] = useState({
  180. balance: [],
  181. usedQuota: [],
  182. requestCount: [],
  183. times: [],
  184. consumeQuota: [],
  185. tokens: [],
  186. rpm: [],
  187. tpm: []
  188. });
  189. // ========== Additional Refs for new cards ==========
  190. const announcementScrollRef = useRef(null);
  191. const faqScrollRef = useRef(null);
  192. const uptimeScrollRef = useRef(null);
  193. const uptimeTabScrollRefs = useRef({});
  194. // ========== Additional State for scroll hints ==========
  195. const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
  196. const [showFaqScrollHint, setShowFaqScrollHint] = useState(false);
  197. const [showUptimeScrollHint, setShowUptimeScrollHint] = useState(false);
  198. // ========== Uptime data ==========
  199. const [uptimeData, setUptimeData] = useState([]);
  200. const [uptimeLoading, setUptimeLoading] = useState(false);
  201. const [activeUptimeTab, setActiveUptimeTab] = useState('');
  202. // ========== Props Destructuring ==========
  203. const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
  204. // ========== Chart Specs State ==========
  205. const [spec_pie, setSpecPie] = useState({
  206. type: 'pie',
  207. data: [
  208. {
  209. id: 'id0',
  210. values: pieData,
  211. },
  212. ],
  213. outerRadius: 0.8,
  214. innerRadius: 0.5,
  215. padAngle: 0.6,
  216. valueField: 'value',
  217. categoryField: 'type',
  218. pie: {
  219. style: {
  220. cornerRadius: 10,
  221. },
  222. state: {
  223. hover: {
  224. outerRadius: 0.85,
  225. stroke: '#000',
  226. lineWidth: 1,
  227. },
  228. selected: {
  229. outerRadius: 0.85,
  230. stroke: '#000',
  231. lineWidth: 1,
  232. },
  233. },
  234. },
  235. title: {
  236. visible: true,
  237. text: t('模型调用次数占比'),
  238. subtext: `${t('总计')}:${renderNumber(times)}`,
  239. },
  240. legends: {
  241. visible: true,
  242. orient: 'left',
  243. },
  244. label: {
  245. visible: true,
  246. },
  247. tooltip: {
  248. mark: {
  249. content: [
  250. {
  251. key: (datum) => datum['type'],
  252. value: (datum) => renderNumber(datum['value']),
  253. },
  254. ],
  255. },
  256. },
  257. color: {
  258. specified: modelColorMap,
  259. },
  260. });
  261. const [spec_line, setSpecLine] = useState({
  262. type: 'bar',
  263. data: [
  264. {
  265. id: 'barData',
  266. values: lineData,
  267. },
  268. ],
  269. xField: 'Time',
  270. yField: 'Usage',
  271. seriesField: 'Model',
  272. stack: true,
  273. legends: {
  274. visible: true,
  275. selectMode: 'single',
  276. },
  277. title: {
  278. visible: true,
  279. text: t('模型消耗分布'),
  280. subtext: `${t('总计')}:${renderQuota(consumeQuota, 2)}`,
  281. },
  282. bar: {
  283. state: {
  284. hover: {
  285. stroke: '#000',
  286. lineWidth: 1,
  287. },
  288. },
  289. },
  290. tooltip: {
  291. mark: {
  292. content: [
  293. {
  294. key: (datum) => datum['Model'],
  295. value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
  296. },
  297. ],
  298. },
  299. dimension: {
  300. content: [
  301. {
  302. key: (datum) => datum['Model'],
  303. value: (datum) => datum['rawQuota'] || 0,
  304. },
  305. ],
  306. updateContent: (array) => {
  307. array.sort((a, b) => b.value - a.value);
  308. let sum = 0;
  309. for (let i = 0; i < array.length; i++) {
  310. if (array[i].key == '其他') {
  311. continue;
  312. }
  313. let value = parseFloat(array[i].value);
  314. if (isNaN(value)) {
  315. value = 0;
  316. }
  317. if (array[i].datum && array[i].datum.TimeSum) {
  318. sum = array[i].datum.TimeSum;
  319. }
  320. array[i].value = renderQuota(value, 4);
  321. }
  322. array.unshift({
  323. key: t('总计'),
  324. value: renderQuota(sum, 4),
  325. });
  326. return array;
  327. },
  328. },
  329. },
  330. color: {
  331. specified: modelColorMap,
  332. },
  333. });
  334. // ========== Hooks - Memoized Values ==========
  335. const performanceMetrics = useMemo(() => {
  336. const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
  337. const avgRPM = (times / timeDiff).toFixed(3);
  338. const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3);
  339. return { avgRPM, avgTPM, timeDiff };
  340. }, [times, consumeTokens, end_timestamp, start_timestamp]);
  341. const getGreeting = useMemo(() => {
  342. const hours = new Date().getHours();
  343. let greeting = '';
  344. if (hours >= 5 && hours < 12) {
  345. greeting = t('早上好');
  346. } else if (hours >= 12 && hours < 14) {
  347. greeting = t('中午好');
  348. } else if (hours >= 14 && hours < 18) {
  349. greeting = t('下午好');
  350. } else {
  351. greeting = t('晚上好');
  352. }
  353. const username = userState?.user?.username || '';
  354. return `👋${greeting},${username}`;
  355. }, [t, userState?.user?.username]);
  356. // ========== Hooks - Callbacks ==========
  357. const getTrendSpec = useCallback((data, color) => ({
  358. type: 'line',
  359. data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
  360. xField: 'x',
  361. yField: 'y',
  362. height: 40,
  363. width: 100,
  364. axes: [
  365. {
  366. orient: 'bottom',
  367. visible: false
  368. },
  369. {
  370. orient: 'left',
  371. visible: false
  372. }
  373. ],
  374. padding: 0,
  375. autoFit: false,
  376. legends: { visible: false },
  377. tooltip: { visible: false },
  378. crosshair: { visible: false },
  379. line: {
  380. style: {
  381. stroke: color,
  382. lineWidth: 2
  383. }
  384. },
  385. point: {
  386. visible: false
  387. },
  388. background: {
  389. fill: 'transparent'
  390. }
  391. }), []);
  392. const groupedStatsData = useMemo(() => [
  393. {
  394. title: createSectionTitle(Wallet, t('账户数据')),
  395. color: 'bg-blue-50',
  396. items: [
  397. {
  398. title: t('当前余额'),
  399. value: renderQuota(userState?.user?.quota),
  400. icon: <IconMoneyExchangeStroked size="large" />,
  401. avatarColor: 'blue',
  402. onClick: () => navigate('/console/topup'),
  403. trendData: [],
  404. trendColor: '#3b82f6'
  405. },
  406. {
  407. title: t('历史消耗'),
  408. value: renderQuota(userState?.user?.used_quota),
  409. icon: <IconHistogram size="large" />,
  410. avatarColor: 'purple',
  411. trendData: [],
  412. trendColor: '#8b5cf6'
  413. }
  414. ]
  415. },
  416. {
  417. title: createSectionTitle(Activity, t('使用统计')),
  418. color: 'bg-green-50',
  419. items: [
  420. {
  421. title: t('请求次数'),
  422. value: userState.user?.request_count,
  423. icon: <IconRotate size="large" />,
  424. avatarColor: 'green',
  425. trendData: [],
  426. trendColor: '#10b981'
  427. },
  428. {
  429. title: t('统计次数'),
  430. value: times,
  431. icon: <IconPulse size="large" />,
  432. avatarColor: 'cyan',
  433. trendData: trendData.times,
  434. trendColor: '#06b6d4'
  435. }
  436. ]
  437. },
  438. {
  439. title: createSectionTitle(Zap, t('资源消耗')),
  440. color: 'bg-yellow-50',
  441. items: [
  442. {
  443. title: t('统计额度'),
  444. value: renderQuota(consumeQuota),
  445. icon: <IconCoinMoneyStroked size="large" />,
  446. avatarColor: 'yellow',
  447. trendData: trendData.consumeQuota,
  448. trendColor: '#f59e0b'
  449. },
  450. {
  451. title: t('统计Tokens'),
  452. value: isNaN(consumeTokens) ? 0 : consumeTokens,
  453. icon: <IconTextStroked size="large" />,
  454. avatarColor: 'pink',
  455. trendData: trendData.tokens,
  456. trendColor: '#ec4899'
  457. }
  458. ]
  459. },
  460. {
  461. title: createSectionTitle(Gauge, t('性能指标')),
  462. color: 'bg-indigo-50',
  463. items: [
  464. {
  465. title: t('平均RPM'),
  466. value: performanceMetrics.avgRPM,
  467. icon: <IconStopwatchStroked size="large" />,
  468. avatarColor: 'indigo',
  469. trendData: trendData.rpm,
  470. trendColor: '#6366f1'
  471. },
  472. {
  473. title: t('平均TPM'),
  474. value: performanceMetrics.avgTPM,
  475. icon: <IconTypograph size="large" />,
  476. avatarColor: 'orange',
  477. trendData: trendData.tpm,
  478. trendColor: '#f97316'
  479. }
  480. ]
  481. }
  482. ], [
  483. createSectionTitle, t, userState?.user?.quota, userState?.user?.used_quota, userState?.user?.request_count,
  484. times, consumeQuota, consumeTokens, trendData, performanceMetrics, navigate
  485. ]);
  486. const handleCopyUrl = useCallback(async (url) => {
  487. if (await copy(url)) {
  488. showSuccess(t('复制成功'));
  489. }
  490. }, [t]);
  491. const handleSpeedTest = useCallback((apiUrl) => {
  492. const encodedUrl = encodeURIComponent(apiUrl);
  493. const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`;
  494. window.open(speedTestUrl, '_blank');
  495. }, []);
  496. const handleInputChange = useCallback((value, name) => {
  497. if (name === 'data_export_default_time') {
  498. setDataExportDefaultTime(value);
  499. return;
  500. }
  501. setInputs((inputs) => ({ ...inputs, [name]: value }));
  502. }, []);
  503. const loadQuotaData = useCallback(async () => {
  504. setLoading(true);
  505. try {
  506. let url = '';
  507. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  508. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  509. if (isAdminUser) {
  510. url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  511. } else {
  512. url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  513. }
  514. const res = await API.get(url);
  515. const { success, message, data } = res.data;
  516. if (success) {
  517. setQuotaData(data);
  518. if (data.length === 0) {
  519. data.push({
  520. count: 0,
  521. model_name: '无数据',
  522. quota: 0,
  523. created_at: now.getTime() / 1000,
  524. });
  525. }
  526. data.sort((a, b) => a.created_at - b.created_at);
  527. updateChartData(data);
  528. } else {
  529. showError(message);
  530. }
  531. } finally {
  532. setLoading(false);
  533. }
  534. }, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
  535. const loadUptimeData = useCallback(async () => {
  536. setUptimeLoading(true);
  537. try {
  538. const res = await API.get('/api/uptime/status');
  539. const { success, message, data } = res.data;
  540. if (success) {
  541. setUptimeData(data || []);
  542. if (data && data.length > 0 && !activeUptimeTab) {
  543. setActiveUptimeTab(data[0].categoryName);
  544. }
  545. } else {
  546. showError(message);
  547. }
  548. } catch (err) {
  549. console.error(err);
  550. } finally {
  551. setUptimeLoading(false);
  552. }
  553. }, [activeUptimeTab]);
  554. const refresh = useCallback(async () => {
  555. await Promise.all([loadQuotaData(), loadUptimeData()]);
  556. }, [loadQuotaData, loadUptimeData]);
  557. const handleSearchConfirm = useCallback(() => {
  558. refresh();
  559. setSearchModalVisible(false);
  560. }, [refresh]);
  561. const initChart = useCallback(async () => {
  562. await loadQuotaData();
  563. await loadUptimeData();
  564. }, [loadQuotaData, loadUptimeData]);
  565. const showSearchModal = useCallback(() => {
  566. setSearchModalVisible(true);
  567. }, []);
  568. const handleCloseModal = useCallback(() => {
  569. setSearchModalVisible(false);
  570. }, []);
  571. // ========== Regular Functions ==========
  572. const checkApiScrollable = () => {
  573. if (apiScrollRef.current) {
  574. const element = apiScrollRef.current;
  575. const isScrollable = element.scrollHeight > element.clientHeight;
  576. const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5;
  577. setShowApiScrollHint(isScrollable && !isAtBottom);
  578. }
  579. };
  580. const handleApiScroll = () => {
  581. checkApiScrollable();
  582. };
  583. const checkCardScrollable = (ref, setHintFunction) => {
  584. if (ref.current) {
  585. const element = ref.current;
  586. const isScrollable = element.scrollHeight > element.clientHeight;
  587. const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5;
  588. setHintFunction(isScrollable && !isAtBottom);
  589. }
  590. };
  591. const handleCardScroll = (ref, setHintFunction) => {
  592. checkCardScrollable(ref, setHintFunction);
  593. };
  594. // ========== Effects for scroll detection ==========
  595. useEffect(() => {
  596. const timer = setTimeout(() => {
  597. checkApiScrollable();
  598. checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
  599. checkCardScrollable(faqScrollRef, setShowFaqScrollHint);
  600. if (uptimeData.length === 1) {
  601. checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
  602. } else if (uptimeData.length > 1 && activeUptimeTab) {
  603. const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab];
  604. if (activeTabRef) {
  605. checkCardScrollable(activeTabRef, setShowUptimeScrollHint);
  606. }
  607. }
  608. }, 100);
  609. return () => clearTimeout(timer);
  610. }, [uptimeData, activeUptimeTab]);
  611. const getUserData = async () => {
  612. let res = await API.get(`/api/user/self`);
  613. const { success, message, data } = res.data;
  614. if (success) {
  615. userDispatch({ type: 'login', payload: data });
  616. } else {
  617. showError(message);
  618. }
  619. };
  620. // ========== Data Processing Functions ==========
  621. const processRawData = useCallback((data) => {
  622. const result = {
  623. totalQuota: 0,
  624. totalTimes: 0,
  625. totalTokens: 0,
  626. uniqueModels: new Set(),
  627. timePoints: [],
  628. timeQuotaMap: new Map(),
  629. timeTokensMap: new Map(),
  630. timeCountMap: new Map()
  631. };
  632. data.forEach((item) => {
  633. result.uniqueModels.add(item.model_name);
  634. result.totalTokens += item.token_used;
  635. result.totalQuota += item.quota;
  636. result.totalTimes += item.count;
  637. const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
  638. if (!result.timePoints.includes(timeKey)) {
  639. result.timePoints.push(timeKey);
  640. }
  641. initializeMaps(timeKey, result.timeQuotaMap, result.timeTokensMap, result.timeCountMap);
  642. updateMapValue(result.timeQuotaMap, timeKey, item.quota);
  643. updateMapValue(result.timeTokensMap, timeKey, item.token_used);
  644. updateMapValue(result.timeCountMap, timeKey, item.count);
  645. });
  646. result.timePoints.sort();
  647. return result;
  648. }, [dataExportDefaultTime, initializeMaps, updateMapValue]);
  649. const calculateTrendData = useCallback((timePoints, timeQuotaMap, timeTokensMap, timeCountMap) => {
  650. const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0);
  651. const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0);
  652. const countTrend = timePoints.map(time => timeCountMap.get(time) || 0);
  653. const rpmTrend = [];
  654. const tpmTrend = [];
  655. if (timePoints.length >= 2) {
  656. const interval = getTimeInterval(dataExportDefaultTime);
  657. for (let i = 0; i < timePoints.length; i++) {
  658. rpmTrend.push(timeCountMap.get(timePoints[i]) / interval);
  659. tpmTrend.push(timeTokensMap.get(timePoints[i]) / interval);
  660. }
  661. }
  662. return {
  663. balance: [],
  664. usedQuota: [],
  665. requestCount: [],
  666. times: countTrend,
  667. consumeQuota: quotaTrend,
  668. tokens: tokensTrend,
  669. rpm: rpmTrend,
  670. tpm: tpmTrend
  671. };
  672. }, [dataExportDefaultTime, getTimeInterval]);
  673. const generateModelColors = useCallback((uniqueModels) => {
  674. const newModelColors = {};
  675. Array.from(uniqueModels).forEach((modelName) => {
  676. newModelColors[modelName] =
  677. modelColorMap[modelName] ||
  678. modelColors[modelName] ||
  679. modelToColor(modelName);
  680. });
  681. return newModelColors;
  682. }, [modelColors]);
  683. const aggregateDataByTimeAndModel = useCallback((data) => {
  684. const aggregatedData = new Map();
  685. data.forEach((item) => {
  686. const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
  687. const modelKey = item.model_name;
  688. const key = `${timeKey}-${modelKey}`;
  689. if (!aggregatedData.has(key)) {
  690. aggregatedData.set(key, {
  691. time: timeKey,
  692. model: modelKey,
  693. quota: 0,
  694. count: 0,
  695. });
  696. }
  697. const existing = aggregatedData.get(key);
  698. existing.quota += item.quota;
  699. existing.count += item.count;
  700. });
  701. return aggregatedData;
  702. }, [dataExportDefaultTime]);
  703. const generateChartTimePoints = useCallback((aggregatedData, data) => {
  704. let chartTimePoints = Array.from(
  705. new Set([...aggregatedData.values()].map((d) => d.time)),
  706. );
  707. if (chartTimePoints.length < 7) {
  708. const lastTime = Math.max(...data.map((item) => item.created_at));
  709. const interval = getTimeInterval(dataExportDefaultTime, true);
  710. chartTimePoints = Array.from({ length: 7 }, (_, i) =>
  711. timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
  712. );
  713. }
  714. return chartTimePoints;
  715. }, [dataExportDefaultTime, getTimeInterval]);
  716. const updateChartData = useCallback((data) => {
  717. const processedData = processRawData(data);
  718. const { totalQuota, totalTimes, totalTokens, uniqueModels, timePoints, timeQuotaMap, timeTokensMap, timeCountMap } = processedData;
  719. const trendDataResult = calculateTrendData(timePoints, timeQuotaMap, timeTokensMap, timeCountMap);
  720. setTrendData(trendDataResult);
  721. const newModelColors = generateModelColors(uniqueModels);
  722. setModelColors(newModelColors);
  723. const aggregatedData = aggregateDataByTimeAndModel(data);
  724. const modelTotals = new Map();
  725. for (let [_, value] of aggregatedData) {
  726. updateMapValue(modelTotals, value.model, value.count);
  727. }
  728. const newPieData = Array.from(modelTotals).map(([model, count]) => ({
  729. type: model,
  730. value: count,
  731. })).sort((a, b) => b.value - a.value);
  732. const chartTimePoints = generateChartTimePoints(aggregatedData, data);
  733. let newLineData = [];
  734. chartTimePoints.forEach((time) => {
  735. let timeData = Array.from(uniqueModels).map((model) => {
  736. const key = `${time}-${model}`;
  737. const aggregated = aggregatedData.get(key);
  738. return {
  739. Time: time,
  740. Model: model,
  741. rawQuota: aggregated?.quota || 0,
  742. Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0,
  743. };
  744. });
  745. const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
  746. timeData.sort((a, b) => b.rawQuota - a.rawQuota);
  747. timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }));
  748. newLineData.push(...timeData);
  749. });
  750. newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
  751. updateChartSpec(
  752. setSpecPie,
  753. newPieData,
  754. `${t('总计')}:${renderNumber(totalTimes)}`,
  755. newModelColors,
  756. 'id0'
  757. );
  758. updateChartSpec(
  759. setSpecLine,
  760. newLineData,
  761. `${t('总计')}:${renderQuota(totalQuota, 2)}`,
  762. newModelColors,
  763. 'barData'
  764. );
  765. setPieData(newPieData);
  766. setLineData(newLineData);
  767. setConsumeQuota(totalQuota);
  768. setTimes(totalTimes);
  769. setConsumeTokens(totalTokens);
  770. }, [
  771. processRawData, calculateTrendData, generateModelColors, aggregateDataByTimeAndModel,
  772. generateChartTimePoints, updateChartSpec, updateMapValue, t
  773. ]);
  774. // ========== Status Data Management ==========
  775. const announcementLegendData = useMemo(() => [
  776. { color: 'grey', label: t('默认'), type: 'default' },
  777. { color: 'blue', label: t('进行中'), type: 'ongoing' },
  778. { color: 'green', label: t('成功'), type: 'success' },
  779. { color: 'orange', label: t('警告'), type: 'warning' },
  780. { color: 'red', label: t('异常'), type: 'error' }
  781. ], [t]);
  782. const uptimeStatusMap = useMemo(() => ({
  783. 1: { color: '#10b981', label: t('正常'), text: t('可用率') }, // UP
  784. 0: { color: '#ef4444', label: t('异常'), text: t('有异常') }, // DOWN
  785. 2: { color: '#f59e0b', label: t('高延迟'), text: t('高延迟') }, // PENDING
  786. 3: { color: '#3b82f6', label: t('维护中'), text: t('维护中') } // MAINTENANCE
  787. }), [t]);
  788. const uptimeLegendData = useMemo(() =>
  789. Object.entries(uptimeStatusMap).map(([status, info]) => ({
  790. status: Number(status),
  791. color: info.color,
  792. label: info.label
  793. })), [uptimeStatusMap]);
  794. const getUptimeStatusColor = useCallback((status) =>
  795. uptimeStatusMap[status]?.color || '#8b9aa7',
  796. [uptimeStatusMap]);
  797. const getUptimeStatusText = useCallback((status) =>
  798. uptimeStatusMap[status]?.text || t('未知'),
  799. [uptimeStatusMap, t]);
  800. const apiInfoData = useMemo(() => {
  801. return statusState?.status?.api_info || [];
  802. }, [statusState?.status?.api_info]);
  803. const announcementData = useMemo(() => {
  804. const announcements = statusState?.status?.announcements || [];
  805. return announcements.map(item => ({
  806. ...item,
  807. time: getRelativeTime(item.publishDate)
  808. }));
  809. }, [statusState?.status?.announcements]);
  810. const faqData = useMemo(() => {
  811. return statusState?.status?.faq || [];
  812. }, [statusState?.status?.faq]);
  813. const renderMonitorList = useCallback((monitors) => {
  814. if (!monitors || monitors.length === 0) {
  815. return (
  816. <div className="flex justify-center items-center py-4">
  817. <Empty
  818. image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
  819. darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
  820. title={t('暂无监控数据')}
  821. />
  822. </div>
  823. );
  824. }
  825. const grouped = {};
  826. monitors.forEach((m) => {
  827. const g = m.group || '';
  828. if (!grouped[g]) grouped[g] = [];
  829. grouped[g].push(m);
  830. });
  831. const renderItem = (monitor, idx) => (
  832. <div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
  833. <div className="flex items-center justify-between mb-1">
  834. <div className="flex items-center gap-2">
  835. <div
  836. className="w-2 h-2 rounded-full flex-shrink-0"
  837. style={{ backgroundColor: getUptimeStatusColor(monitor.status) }}
  838. />
  839. <span className="text-sm font-medium text-gray-900">{monitor.name}</span>
  840. </div>
  841. <span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
  842. </div>
  843. <div className="flex items-center gap-2">
  844. <span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
  845. <div className="flex-1">
  846. <Progress
  847. percent={(monitor.uptime || 0) * 100}
  848. showInfo={false}
  849. aria-label={`${monitor.name} uptime`}
  850. stroke={getUptimeStatusColor(monitor.status)}
  851. />
  852. </div>
  853. </div>
  854. </div>
  855. );
  856. return Object.entries(grouped).map(([gname, list]) => (
  857. <div key={gname || 'default'} className="mb-2">
  858. {gname && (
  859. <>
  860. <div className="text-md font-semibold text-gray-500 px-2 py-1">
  861. {gname}
  862. </div>
  863. <Divider />
  864. </>
  865. )}
  866. {list.map(renderItem)}
  867. </div>
  868. ));
  869. }, [t, getUptimeStatusColor, getUptimeStatusText]);
  870. // ========== Hooks - Effects ==========
  871. useEffect(() => {
  872. getUserData();
  873. if (!initialized.current) {
  874. initVChartSemiTheme({
  875. isWatchingThemeSwitch: true,
  876. });
  877. initialized.current = true;
  878. initChart();
  879. }
  880. }, []);
  881. return (
  882. <div className="bg-gray-50 h-full">
  883. <div className="flex items-center justify-between mb-4">
  884. <h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
  885. <div className="flex gap-3">
  886. <IconButton
  887. icon={<IconSearch />}
  888. onClick={showSearchModal}
  889. className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
  890. />
  891. <IconButton
  892. icon={<IconRefresh />}
  893. onClick={refresh}
  894. loading={loading}
  895. className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
  896. />
  897. </div>
  898. </div>
  899. {/* 搜索条件Modal */}
  900. <Modal
  901. title={t('搜索条件')}
  902. visible={searchModalVisible}
  903. onOk={handleSearchConfirm}
  904. onCancel={handleCloseModal}
  905. closeOnEsc={true}
  906. size={isMobile() ? 'full-width' : 'small'}
  907. centered
  908. >
  909. <Form ref={formRef} layout='vertical' className="w-full">
  910. {createFormField(Form.DatePicker, {
  911. field: 'start_timestamp',
  912. label: t('起始时间'),
  913. initValue: start_timestamp,
  914. value: start_timestamp,
  915. type: 'dateTime',
  916. name: 'start_timestamp',
  917. onChange: (value) => handleInputChange(value, 'start_timestamp')
  918. })}
  919. {createFormField(Form.DatePicker, {
  920. field: 'end_timestamp',
  921. label: t('结束时间'),
  922. initValue: end_timestamp,
  923. value: end_timestamp,
  924. type: 'dateTime',
  925. name: 'end_timestamp',
  926. onChange: (value) => handleInputChange(value, 'end_timestamp')
  927. })}
  928. {createFormField(Form.Select, {
  929. field: 'data_export_default_time',
  930. label: t('时间粒度'),
  931. initValue: dataExportDefaultTime,
  932. placeholder: t('时间粒度'),
  933. name: 'data_export_default_time',
  934. optionList: timeOptions,
  935. onChange: (value) => handleInputChange(value, 'data_export_default_time')
  936. })}
  937. {isAdminUser && createFormField(Form.Input, {
  938. field: 'username',
  939. label: t('用户名称'),
  940. value: username,
  941. placeholder: t('可选值'),
  942. name: 'username',
  943. onChange: (value) => handleInputChange(value, 'username')
  944. })}
  945. </Form>
  946. </Modal>
  947. <Spin spinning={loading}>
  948. <div className="mb-4">
  949. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  950. {groupedStatsData.map((group, idx) => (
  951. <Card
  952. key={idx}
  953. {...CARD_PROPS}
  954. className={`${group.color} border-0 !rounded-2xl w-full`}
  955. title={group.title}
  956. >
  957. <div className="space-y-4">
  958. {group.items.map((item, itemIdx) => (
  959. <div
  960. key={itemIdx}
  961. className="flex items-center justify-between cursor-pointer"
  962. onClick={item.onClick}
  963. >
  964. <div className="flex items-center">
  965. <Avatar
  966. className="mr-3"
  967. size="small"
  968. color={item.avatarColor}
  969. >
  970. {item.icon}
  971. </Avatar>
  972. <div>
  973. <div className="text-xs text-gray-500">{item.title}</div>
  974. <div className="text-lg font-semibold">{item.value}</div>
  975. </div>
  976. </div>
  977. {item.trendData && item.trendData.length > 0 && (
  978. <div className="w-24 h-10">
  979. <VChart
  980. spec={getTrendSpec(item.trendData, item.trendColor)}
  981. option={CHART_CONFIG}
  982. />
  983. </div>
  984. )}
  985. </div>
  986. ))}
  987. </div>
  988. </Card>
  989. ))}
  990. </div>
  991. </div>
  992. <div className="mb-4">
  993. <div className={`grid grid-cols-1 gap-4 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
  994. <Card
  995. {...CARD_PROPS}
  996. className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
  997. title={
  998. <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
  999. <div className={FLEX_CENTER_GAP2}>
  1000. <PieChart size={16} />
  1001. {t('模型数据分析')}
  1002. </div>
  1003. <Tabs
  1004. type="button"
  1005. activeKey={activeChartTab}
  1006. onChange={setActiveChartTab}
  1007. >
  1008. <TabPane tab={
  1009. <span>
  1010. <IconHistogram />
  1011. {t('消耗分布')}
  1012. </span>
  1013. } itemKey="1" />
  1014. <TabPane tab={
  1015. <span>
  1016. <IconPieChart2Stroked />
  1017. {t('调用次数分布')}
  1018. </span>
  1019. } itemKey="2" />
  1020. </Tabs>
  1021. </div>
  1022. }
  1023. >
  1024. <div style={{ height: 400 }}>
  1025. {activeChartTab === '1' ? (
  1026. <VChart
  1027. spec={spec_line}
  1028. option={CHART_CONFIG}
  1029. />
  1030. ) : (
  1031. <VChart
  1032. spec={spec_pie}
  1033. option={CHART_CONFIG}
  1034. />
  1035. )}
  1036. </div>
  1037. </Card>
  1038. {hasApiInfoPanel && (
  1039. <Card
  1040. {...CARD_PROPS}
  1041. className="bg-gray-50 border-0 !rounded-2xl"
  1042. title={
  1043. <div className={FLEX_CENTER_GAP2}>
  1044. <Server size={16} />
  1045. {t('API信息')}
  1046. </div>
  1047. }
  1048. >
  1049. <div className="card-content-container">
  1050. <div
  1051. ref={apiScrollRef}
  1052. className="space-y-3 max-h-96 overflow-y-auto card-content-scroll"
  1053. onScroll={handleApiScroll}
  1054. >
  1055. {apiInfoData.length > 0 ? (
  1056. apiInfoData.map((api) => (
  1057. <div key={api.id} className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
  1058. <div className="flex-shrink-0 mr-3">
  1059. <Avatar
  1060. size="extra-small"
  1061. color={api.color}
  1062. >
  1063. {api.route.substring(0, 2)}
  1064. </Avatar>
  1065. </div>
  1066. <div className="flex-1">
  1067. <div className="text-sm font-medium text-gray-900 mb-1 !font-bold flex items-center gap-2">
  1068. <Tag
  1069. prefixIcon={<Gauge size={12} />}
  1070. size="small"
  1071. color="white"
  1072. shape='circle'
  1073. onClick={() => handleSpeedTest(api.url)}
  1074. className="cursor-pointer hover:opacity-80 text-xs"
  1075. >
  1076. {t('测速')}
  1077. </Tag>
  1078. {api.route}
  1079. </div>
  1080. <div
  1081. className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
  1082. onClick={() => handleCopyUrl(api.url)}
  1083. >
  1084. {api.url}
  1085. </div>
  1086. <div className="text-gray-500">
  1087. {api.description}
  1088. </div>
  1089. </div>
  1090. </div>
  1091. ))
  1092. ) : (
  1093. <div className="flex justify-center items-center py-8">
  1094. <Empty
  1095. image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
  1096. darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
  1097. title={t('暂无API信息')}
  1098. description={t('请联系管理员在系统设置中配置API信息')}
  1099. />
  1100. </div>
  1101. )}
  1102. </div>
  1103. <div
  1104. className="card-content-fade-indicator"
  1105. style={{ opacity: showApiScrollHint ? 1 : 0 }}
  1106. />
  1107. </div>
  1108. </Card>
  1109. )}
  1110. </div>
  1111. </div>
  1112. {/* 系统公告和常见问答卡片 */}
  1113. {hasInfoPanels && (
  1114. <div className="mb-4">
  1115. <div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
  1116. {/* 公告卡片 */}
  1117. {announcementsEnabled && (
  1118. <Card
  1119. {...CARD_PROPS}
  1120. className="shadow-sm !rounded-2xl lg:col-span-2"
  1121. title={
  1122. <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
  1123. <div className="flex items-center gap-2">
  1124. <Bell size={16} />
  1125. {t('系统公告')}
  1126. <Tag size="small" color="grey" shape="circle">
  1127. {t('显示最新20条')}
  1128. </Tag>
  1129. </div>
  1130. {/* 图例 */}
  1131. <div className="flex flex-wrap gap-3 text-xs">
  1132. {announcementLegendData.map((legend, index) => (
  1133. <div key={index} className="flex items-center gap-1">
  1134. <div
  1135. className="w-2 h-2 rounded-full"
  1136. style={{
  1137. backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
  1138. legend.color === 'blue' ? '#3b82f6' :
  1139. legend.color === 'green' ? '#10b981' :
  1140. legend.color === 'orange' ? '#f59e0b' :
  1141. legend.color === 'red' ? '#ef4444' : '#8b9aa7'
  1142. }}
  1143. />
  1144. <span className="text-gray-600">{legend.label}</span>
  1145. </div>
  1146. ))}
  1147. </div>
  1148. </div>
  1149. }
  1150. >
  1151. <div className="card-content-container">
  1152. <div
  1153. ref={announcementScrollRef}
  1154. className="p-2 max-h-96 overflow-y-auto card-content-scroll"
  1155. onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
  1156. >
  1157. {announcementData.length > 0 ? (
  1158. <Timeline
  1159. mode="alternate"
  1160. dataSource={announcementData}
  1161. />
  1162. ) : (
  1163. <div className="flex justify-center items-center py-8">
  1164. <Empty
  1165. image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
  1166. darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
  1167. title={t('暂无系统公告')}
  1168. description={t('请联系管理员在系统设置中配置公告信息')}
  1169. />
  1170. </div>
  1171. )}
  1172. </div>
  1173. <div
  1174. className="card-content-fade-indicator"
  1175. style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
  1176. />
  1177. </div>
  1178. </Card>
  1179. )}
  1180. {/* 常见问答卡片 */}
  1181. {faqEnabled && (
  1182. <Card
  1183. {...CARD_PROPS}
  1184. className="shadow-sm !rounded-2xl lg:col-span-1"
  1185. title={
  1186. <div className={FLEX_CENTER_GAP2}>
  1187. <HelpCircle size={16} />
  1188. {t('常见问答')}
  1189. </div>
  1190. }
  1191. bodyStyle={{ padding: 0 }}
  1192. >
  1193. <div className="card-content-container">
  1194. <div
  1195. ref={faqScrollRef}
  1196. className="p-2 max-h-96 overflow-y-auto card-content-scroll"
  1197. onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
  1198. >
  1199. {faqData.length > 0 ? (
  1200. <Collapse
  1201. accordion
  1202. expandIcon={<IconPlus />}
  1203. collapseIcon={<IconMinus />}
  1204. >
  1205. {faqData.map((item, index) => (
  1206. <Collapse.Panel
  1207. key={index}
  1208. header={item.question}
  1209. itemKey={index.toString()}
  1210. >
  1211. <p>{item.answer}</p>
  1212. </Collapse.Panel>
  1213. ))}
  1214. </Collapse>
  1215. ) : (
  1216. <div className="flex justify-center items-center py-8">
  1217. <Empty
  1218. image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
  1219. darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
  1220. title={t('暂无常见问答')}
  1221. description={t('请联系管理员在系统设置中配置常见问答')}
  1222. />
  1223. </div>
  1224. )}
  1225. </div>
  1226. <div
  1227. className="card-content-fade-indicator"
  1228. style={{ opacity: showFaqScrollHint ? 1 : 0 }}
  1229. />
  1230. </div>
  1231. </Card>
  1232. )}
  1233. {/* 服务可用性卡片 */}
  1234. {uptimeEnabled && (
  1235. <Card
  1236. {...CARD_PROPS}
  1237. className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
  1238. title={
  1239. <div className="flex items-center justify-between w-full gap-2">
  1240. <div className="flex items-center gap-2">
  1241. <Gauge size={16} />
  1242. {t('服务可用性')}
  1243. </div>
  1244. <IconButton
  1245. icon={<IconRefresh />}
  1246. onClick={loadUptimeData}
  1247. loading={uptimeLoading}
  1248. size="small"
  1249. theme="borderless"
  1250. className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
  1251. />
  1252. </div>
  1253. }
  1254. bodyStyle={{ padding: 0 }}
  1255. >
  1256. {/* 内容区域 */}
  1257. <div className="flex-1 relative">
  1258. <Spin spinning={uptimeLoading}>
  1259. {uptimeData.length > 0 ? (
  1260. uptimeData.length === 1 ? (
  1261. <div className="card-content-container">
  1262. <div
  1263. ref={uptimeScrollRef}
  1264. className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
  1265. onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
  1266. >
  1267. {renderMonitorList(uptimeData[0].monitors)}
  1268. </div>
  1269. <div
  1270. className="card-content-fade-indicator"
  1271. style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
  1272. />
  1273. </div>
  1274. ) : (
  1275. <Tabs
  1276. type="card"
  1277. collapsible
  1278. activeKey={activeUptimeTab}
  1279. onChange={setActiveUptimeTab}
  1280. size="small"
  1281. >
  1282. {uptimeData.map((group, groupIdx) => {
  1283. if (!uptimeTabScrollRefs.current[group.categoryName]) {
  1284. uptimeTabScrollRefs.current[group.categoryName] = React.createRef();
  1285. }
  1286. const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName];
  1287. return (
  1288. <TabPane
  1289. tab={
  1290. <span className="flex items-center gap-2">
  1291. <Gauge size={14} />
  1292. {group.categoryName}
  1293. <Tag
  1294. color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
  1295. size='small'
  1296. shape='circle'
  1297. >
  1298. {group.monitors ? group.monitors.length : 0}
  1299. </Tag>
  1300. </span>
  1301. }
  1302. itemKey={group.categoryName}
  1303. key={groupIdx}
  1304. >
  1305. <div className="card-content-container">
  1306. <div
  1307. ref={tabScrollRef}
  1308. className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
  1309. onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
  1310. >
  1311. {renderMonitorList(group.monitors)}
  1312. </div>
  1313. <div
  1314. className="card-content-fade-indicator"
  1315. style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
  1316. />
  1317. </div>
  1318. </TabPane>
  1319. );
  1320. })}
  1321. </Tabs>
  1322. )
  1323. ) : (
  1324. <div className="flex justify-center items-center py-8">
  1325. <Empty
  1326. image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
  1327. darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
  1328. title={t('暂无监控数据')}
  1329. description={t('请联系管理员在系统设置中配置Uptime')}
  1330. />
  1331. </div>
  1332. )}
  1333. </Spin>
  1334. </div>
  1335. {/* 固定在底部的图例 */}
  1336. {uptimeData.length > 0 && (
  1337. <div className="p-3 mt-auto bg-gray-50 rounded-b-2xl">
  1338. <div className="flex flex-wrap gap-3 text-xs justify-center">
  1339. {uptimeLegendData.map((legend, index) => (
  1340. <div key={index} className="flex items-center gap-1">
  1341. <div
  1342. className="w-2 h-2 rounded-full"
  1343. style={{ backgroundColor: legend.color }}
  1344. />
  1345. <span className="text-gray-600">{legend.label}</span>
  1346. </div>
  1347. ))}
  1348. </div>
  1349. </div>
  1350. )}
  1351. </Card>
  1352. )}
  1353. </div>
  1354. </div>
  1355. )}
  1356. </Spin>
  1357. </div>
  1358. );
  1359. };
  1360. export default Detail;