index.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047
  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 } 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. } from '@douyinfe/semi-ui';
  17. import {
  18. IconRefresh,
  19. IconSearch,
  20. IconMoneyExchangeStroked,
  21. IconHistogram,
  22. IconRotate,
  23. IconCoinMoneyStroked,
  24. IconTextStroked,
  25. IconPulse,
  26. IconStopwatchStroked,
  27. IconTypograph,
  28. IconPieChart2Stroked
  29. } from '@douyinfe/semi-icons';
  30. import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
  31. import { VChart } from '@visactor/react-vchart';
  32. import {
  33. API,
  34. isAdmin,
  35. isMobile,
  36. showError,
  37. timestamp2string,
  38. timestamp2string1,
  39. getQuotaWithUnit,
  40. modelColorMap,
  41. renderNumber,
  42. renderQuota,
  43. modelToColor,
  44. copy,
  45. showSuccess
  46. } from '../../helpers';
  47. import { UserContext } from '../../context/User/index.js';
  48. import { StatusContext } from '../../context/Status/index.js';
  49. import { useTranslation } from 'react-i18next';
  50. const Detail = (props) => {
  51. // ========== Hooks - Context ==========
  52. const [userState, userDispatch] = useContext(UserContext);
  53. const [statusState, statusDispatch] = useContext(StatusContext);
  54. // ========== Hooks - Navigation & Translation ==========
  55. const { t } = useTranslation();
  56. const navigate = useNavigate();
  57. // ========== Hooks - Refs ==========
  58. const formRef = useRef();
  59. const initialized = useRef(false);
  60. const apiScrollRef = useRef(null);
  61. // ========== Constants & Shared Configurations ==========
  62. const CHART_CONFIG = { mode: 'desktop-browser' };
  63. const CARD_PROPS = {
  64. shadows: 'always',
  65. bordered: false,
  66. headerLine: true
  67. };
  68. const FORM_FIELD_PROPS = {
  69. className: "w-full mb-2 !rounded-lg",
  70. size: 'large'
  71. };
  72. const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
  73. const FLEX_CENTER_GAP2 = "flex items-center gap-2";
  74. // ========== Constants ==========
  75. let now = new Date();
  76. const isAdminUser = isAdmin();
  77. // ========== Helper Functions ==========
  78. const getDefaultTime = useCallback(() => {
  79. return localStorage.getItem('data_export_default_time') || 'hour';
  80. }, []);
  81. const getTimeInterval = useCallback((timeType, isSeconds = false) => {
  82. const intervals = {
  83. hour: isSeconds ? 3600 : 60,
  84. day: isSeconds ? 86400 : 1440,
  85. week: isSeconds ? 604800 : 10080
  86. };
  87. return intervals[timeType] || intervals.hour;
  88. }, []);
  89. const getInitialTimestamp = useCallback(() => {
  90. const defaultTime = getDefaultTime();
  91. const now = new Date().getTime() / 1000;
  92. switch (defaultTime) {
  93. case 'hour':
  94. return timestamp2string(now - 86400);
  95. case 'week':
  96. return timestamp2string(now - 86400 * 30);
  97. default:
  98. return timestamp2string(now - 86400 * 7);
  99. }
  100. }, [getDefaultTime]);
  101. const updateMapValue = useCallback((map, key, value) => {
  102. if (!map.has(key)) {
  103. map.set(key, 0);
  104. }
  105. map.set(key, map.get(key) + value);
  106. }, []);
  107. const initializeMaps = useCallback((key, ...maps) => {
  108. maps.forEach(map => {
  109. if (!map.has(key)) {
  110. map.set(key, 0);
  111. }
  112. });
  113. }, []);
  114. const updateChartSpec = useCallback((setterFunc, newData, subtitle, newColors, dataId) => {
  115. setterFunc(prev => ({
  116. ...prev,
  117. data: [{ id: dataId, values: newData }],
  118. title: {
  119. ...prev.title,
  120. subtext: subtitle,
  121. },
  122. color: {
  123. specified: newColors,
  124. },
  125. }));
  126. }, []);
  127. const createSectionTitle = useCallback((Icon, text) => (
  128. <div className={FLEX_CENTER_GAP2}>
  129. <Icon size={16} />
  130. {text}
  131. </div>
  132. ), []);
  133. const createFormField = useCallback((Component, props) => (
  134. <Component {...FORM_FIELD_PROPS} {...props} />
  135. ), []);
  136. // ========== Time Options ==========
  137. const timeOptions = useMemo(() => [
  138. { label: t('小时'), value: 'hour' },
  139. { label: t('天'), value: 'day' },
  140. { label: t('周'), value: 'week' },
  141. ], [t]);
  142. // ========== Hooks - State ==========
  143. const [inputs, setInputs] = useState({
  144. username: '',
  145. token_name: '',
  146. model_name: '',
  147. start_timestamp: getInitialTimestamp(),
  148. end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
  149. channel: '',
  150. data_export_default_time: '',
  151. });
  152. const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime());
  153. const [loading, setLoading] = useState(false);
  154. const [quotaData, setQuotaData] = useState([]);
  155. const [consumeQuota, setConsumeQuota] = useState(0);
  156. const [consumeTokens, setConsumeTokens] = useState(0);
  157. const [times, setTimes] = useState(0);
  158. const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
  159. const [lineData, setLineData] = useState([]);
  160. const [apiInfoData, setApiInfoData] = useState([]);
  161. const [modelColors, setModelColors] = useState({});
  162. const [activeChartTab, setActiveChartTab] = useState('1');
  163. const [showApiScrollHint, setShowApiScrollHint] = useState(false);
  164. const [searchModalVisible, setSearchModalVisible] = useState(false);
  165. const [trendData, setTrendData] = useState({
  166. balance: [],
  167. usedQuota: [],
  168. requestCount: [],
  169. times: [],
  170. consumeQuota: [],
  171. tokens: [],
  172. rpm: [],
  173. tpm: []
  174. });
  175. // ========== Props Destructuring ==========
  176. const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
  177. // ========== Chart Specs State ==========
  178. const [spec_pie, setSpecPie] = useState({
  179. type: 'pie',
  180. data: [
  181. {
  182. id: 'id0',
  183. values: pieData,
  184. },
  185. ],
  186. outerRadius: 0.8,
  187. innerRadius: 0.5,
  188. padAngle: 0.6,
  189. valueField: 'value',
  190. categoryField: 'type',
  191. pie: {
  192. style: {
  193. cornerRadius: 10,
  194. },
  195. state: {
  196. hover: {
  197. outerRadius: 0.85,
  198. stroke: '#000',
  199. lineWidth: 1,
  200. },
  201. selected: {
  202. outerRadius: 0.85,
  203. stroke: '#000',
  204. lineWidth: 1,
  205. },
  206. },
  207. },
  208. title: {
  209. visible: true,
  210. text: t('模型调用次数占比'),
  211. subtext: `${t('总计')}:${renderNumber(times)}`,
  212. },
  213. legends: {
  214. visible: true,
  215. orient: 'left',
  216. },
  217. label: {
  218. visible: true,
  219. },
  220. tooltip: {
  221. mark: {
  222. content: [
  223. {
  224. key: (datum) => datum['type'],
  225. value: (datum) => renderNumber(datum['value']),
  226. },
  227. ],
  228. },
  229. },
  230. color: {
  231. specified: modelColorMap,
  232. },
  233. });
  234. const [spec_line, setSpecLine] = useState({
  235. type: 'bar',
  236. data: [
  237. {
  238. id: 'barData',
  239. values: lineData,
  240. },
  241. ],
  242. xField: 'Time',
  243. yField: 'Usage',
  244. seriesField: 'Model',
  245. stack: true,
  246. legends: {
  247. visible: true,
  248. selectMode: 'single',
  249. },
  250. title: {
  251. visible: true,
  252. text: t('模型消耗分布'),
  253. subtext: `${t('总计')}:${renderQuota(consumeQuota, 2)}`,
  254. },
  255. bar: {
  256. state: {
  257. hover: {
  258. stroke: '#000',
  259. lineWidth: 1,
  260. },
  261. },
  262. },
  263. tooltip: {
  264. mark: {
  265. content: [
  266. {
  267. key: (datum) => datum['Model'],
  268. value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
  269. },
  270. ],
  271. },
  272. dimension: {
  273. content: [
  274. {
  275. key: (datum) => datum['Model'],
  276. value: (datum) => datum['rawQuota'] || 0,
  277. },
  278. ],
  279. updateContent: (array) => {
  280. array.sort((a, b) => b.value - a.value);
  281. let sum = 0;
  282. for (let i = 0; i < array.length; i++) {
  283. if (array[i].key == '其他') {
  284. continue;
  285. }
  286. let value = parseFloat(array[i].value);
  287. if (isNaN(value)) {
  288. value = 0;
  289. }
  290. if (array[i].datum && array[i].datum.TimeSum) {
  291. sum = array[i].datum.TimeSum;
  292. }
  293. array[i].value = renderQuota(value, 4);
  294. }
  295. array.unshift({
  296. key: t('总计'),
  297. value: renderQuota(sum, 4),
  298. });
  299. return array;
  300. },
  301. },
  302. },
  303. color: {
  304. specified: modelColorMap,
  305. },
  306. });
  307. // ========== Hooks - Memoized Values ==========
  308. const performanceMetrics = useMemo(() => {
  309. const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
  310. const avgRPM = (times / timeDiff).toFixed(3);
  311. const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3);
  312. return { avgRPM, avgTPM, timeDiff };
  313. }, [times, consumeTokens, end_timestamp, start_timestamp]);
  314. const getGreeting = useMemo(() => {
  315. const hours = new Date().getHours();
  316. let greeting = '';
  317. if (hours >= 5 && hours < 12) {
  318. greeting = t('早上好');
  319. } else if (hours >= 12 && hours < 14) {
  320. greeting = t('中午好');
  321. } else if (hours >= 14 && hours < 18) {
  322. greeting = t('下午好');
  323. } else {
  324. greeting = t('晚上好');
  325. }
  326. const username = userState?.user?.username || '';
  327. return `👋${greeting},${username}`;
  328. }, [t, userState?.user?.username]);
  329. // ========== Hooks - Callbacks ==========
  330. const getTrendSpec = useCallback((data, color) => ({
  331. type: 'line',
  332. data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
  333. xField: 'x',
  334. yField: 'y',
  335. height: 40,
  336. width: 100,
  337. axes: [
  338. {
  339. orient: 'bottom',
  340. visible: false
  341. },
  342. {
  343. orient: 'left',
  344. visible: false
  345. }
  346. ],
  347. padding: 0,
  348. autoFit: false,
  349. legends: { visible: false },
  350. tooltip: { visible: false },
  351. crosshair: { visible: false },
  352. line: {
  353. style: {
  354. stroke: color,
  355. lineWidth: 2
  356. }
  357. },
  358. point: {
  359. visible: false
  360. },
  361. background: {
  362. fill: 'transparent'
  363. }
  364. }), []);
  365. const groupedStatsData = useMemo(() => [
  366. {
  367. title: createSectionTitle(Wallet, t('账户数据')),
  368. color: 'bg-blue-50',
  369. items: [
  370. {
  371. title: t('当前余额'),
  372. value: renderQuota(userState?.user?.quota),
  373. icon: <IconMoneyExchangeStroked size="large" />,
  374. avatarColor: 'blue',
  375. onClick: () => navigate('/console/topup'),
  376. trendData: [],
  377. trendColor: '#3b82f6'
  378. },
  379. {
  380. title: t('历史消耗'),
  381. value: renderQuota(userState?.user?.used_quota),
  382. icon: <IconHistogram size="large" />,
  383. avatarColor: 'purple',
  384. trendData: [],
  385. trendColor: '#8b5cf6'
  386. }
  387. ]
  388. },
  389. {
  390. title: createSectionTitle(Activity, t('使用统计')),
  391. color: 'bg-green-50',
  392. items: [
  393. {
  394. title: t('请求次数'),
  395. value: userState.user?.request_count,
  396. icon: <IconRotate size="large" />,
  397. avatarColor: 'green',
  398. trendData: [],
  399. trendColor: '#10b981'
  400. },
  401. {
  402. title: t('统计次数'),
  403. value: times,
  404. icon: <IconPulse size="large" />,
  405. avatarColor: 'cyan',
  406. trendData: trendData.times,
  407. trendColor: '#06b6d4'
  408. }
  409. ]
  410. },
  411. {
  412. title: createSectionTitle(Zap, t('资源消耗')),
  413. color: 'bg-yellow-50',
  414. items: [
  415. {
  416. title: t('统计额度'),
  417. value: renderQuota(consumeQuota),
  418. icon: <IconCoinMoneyStroked size="large" />,
  419. avatarColor: 'yellow',
  420. trendData: trendData.consumeQuota,
  421. trendColor: '#f59e0b'
  422. },
  423. {
  424. title: t('统计Tokens'),
  425. value: isNaN(consumeTokens) ? 0 : consumeTokens,
  426. icon: <IconTextStroked size="large" />,
  427. avatarColor: 'pink',
  428. trendData: trendData.tokens,
  429. trendColor: '#ec4899'
  430. }
  431. ]
  432. },
  433. {
  434. title: createSectionTitle(Gauge, t('性能指标')),
  435. color: 'bg-indigo-50',
  436. items: [
  437. {
  438. title: t('平均RPM'),
  439. value: performanceMetrics.avgRPM,
  440. icon: <IconStopwatchStroked size="large" />,
  441. avatarColor: 'indigo',
  442. trendData: trendData.rpm,
  443. trendColor: '#6366f1'
  444. },
  445. {
  446. title: t('平均TPM'),
  447. value: performanceMetrics.avgTPM,
  448. icon: <IconTypograph size="large" />,
  449. avatarColor: 'orange',
  450. trendData: trendData.tpm,
  451. trendColor: '#f97316'
  452. }
  453. ]
  454. }
  455. ], [
  456. createSectionTitle, t, userState?.user?.quota, userState?.user?.used_quota, userState?.user?.request_count,
  457. times, consumeQuota, consumeTokens, trendData, performanceMetrics, navigate
  458. ]);
  459. const handleCopyUrl = useCallback(async (url) => {
  460. if (await copy(url)) {
  461. showSuccess(t('复制成功'));
  462. }
  463. }, [t]);
  464. const handleSpeedTest = useCallback((apiUrl) => {
  465. const encodedUrl = encodeURIComponent(apiUrl);
  466. const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`;
  467. window.open(speedTestUrl, '_blank');
  468. }, []);
  469. const handleInputChange = useCallback((value, name) => {
  470. if (name === 'data_export_default_time') {
  471. setDataExportDefaultTime(value);
  472. return;
  473. }
  474. setInputs((inputs) => ({ ...inputs, [name]: value }));
  475. }, []);
  476. const loadQuotaData = useCallback(async () => {
  477. setLoading(true);
  478. try {
  479. let url = '';
  480. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  481. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  482. if (isAdminUser) {
  483. url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  484. } else {
  485. url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  486. }
  487. const res = await API.get(url);
  488. const { success, message, data } = res.data;
  489. if (success) {
  490. setQuotaData(data);
  491. if (data.length === 0) {
  492. data.push({
  493. count: 0,
  494. model_name: '无数据',
  495. quota: 0,
  496. created_at: now.getTime() / 1000,
  497. });
  498. }
  499. data.sort((a, b) => a.created_at - b.created_at);
  500. updateChartData(data);
  501. } else {
  502. showError(message);
  503. }
  504. } finally {
  505. setLoading(false);
  506. }
  507. }, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
  508. const refresh = useCallback(async () => {
  509. await loadQuotaData();
  510. }, [loadQuotaData]);
  511. const handleSearchConfirm = useCallback(() => {
  512. refresh();
  513. setSearchModalVisible(false);
  514. }, [refresh]);
  515. const initChart = useCallback(async () => {
  516. await loadQuotaData();
  517. }, [loadQuotaData]);
  518. const showSearchModal = useCallback(() => {
  519. setSearchModalVisible(true);
  520. }, []);
  521. const handleCloseModal = useCallback(() => {
  522. setSearchModalVisible(false);
  523. }, []);
  524. // ========== Regular Functions ==========
  525. const checkApiScrollable = () => {
  526. if (apiScrollRef.current) {
  527. const element = apiScrollRef.current;
  528. const isScrollable = element.scrollHeight > element.clientHeight;
  529. const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5;
  530. setShowApiScrollHint(isScrollable && !isAtBottom);
  531. }
  532. };
  533. const handleApiScroll = () => {
  534. checkApiScrollable();
  535. };
  536. const getUserData = async () => {
  537. let res = await API.get(`/api/user/self`);
  538. const { success, message, data } = res.data;
  539. if (success) {
  540. userDispatch({ type: 'login', payload: data });
  541. } else {
  542. showError(message);
  543. }
  544. };
  545. // ========== Data Processing Functions ==========
  546. const processRawData = useCallback((data) => {
  547. const result = {
  548. totalQuota: 0,
  549. totalTimes: 0,
  550. totalTokens: 0,
  551. uniqueModels: new Set(),
  552. timePoints: [],
  553. timeQuotaMap: new Map(),
  554. timeTokensMap: new Map(),
  555. timeCountMap: new Map()
  556. };
  557. data.forEach((item) => {
  558. result.uniqueModels.add(item.model_name);
  559. result.totalTokens += item.token_used;
  560. result.totalQuota += item.quota;
  561. result.totalTimes += item.count;
  562. const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
  563. if (!result.timePoints.includes(timeKey)) {
  564. result.timePoints.push(timeKey);
  565. }
  566. initializeMaps(timeKey, result.timeQuotaMap, result.timeTokensMap, result.timeCountMap);
  567. updateMapValue(result.timeQuotaMap, timeKey, item.quota);
  568. updateMapValue(result.timeTokensMap, timeKey, item.token_used);
  569. updateMapValue(result.timeCountMap, timeKey, item.count);
  570. });
  571. result.timePoints.sort();
  572. return result;
  573. }, [dataExportDefaultTime, initializeMaps, updateMapValue]);
  574. const calculateTrendData = useCallback((timePoints, timeQuotaMap, timeTokensMap, timeCountMap) => {
  575. const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0);
  576. const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0);
  577. const countTrend = timePoints.map(time => timeCountMap.get(time) || 0);
  578. const rpmTrend = [];
  579. const tpmTrend = [];
  580. if (timePoints.length >= 2) {
  581. const interval = getTimeInterval(dataExportDefaultTime);
  582. for (let i = 0; i < timePoints.length; i++) {
  583. rpmTrend.push(timeCountMap.get(timePoints[i]) / interval);
  584. tpmTrend.push(timeTokensMap.get(timePoints[i]) / interval);
  585. }
  586. }
  587. return {
  588. balance: [],
  589. usedQuota: [],
  590. requestCount: [],
  591. times: countTrend,
  592. consumeQuota: quotaTrend,
  593. tokens: tokensTrend,
  594. rpm: rpmTrend,
  595. tpm: tpmTrend
  596. };
  597. }, [dataExportDefaultTime, getTimeInterval]);
  598. const generateModelColors = useCallback((uniqueModels) => {
  599. const newModelColors = {};
  600. Array.from(uniqueModels).forEach((modelName) => {
  601. newModelColors[modelName] =
  602. modelColorMap[modelName] ||
  603. modelColors[modelName] ||
  604. modelToColor(modelName);
  605. });
  606. return newModelColors;
  607. }, [modelColors]);
  608. const aggregateDataByTimeAndModel = useCallback((data) => {
  609. const aggregatedData = new Map();
  610. data.forEach((item) => {
  611. const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
  612. const modelKey = item.model_name;
  613. const key = `${timeKey}-${modelKey}`;
  614. if (!aggregatedData.has(key)) {
  615. aggregatedData.set(key, {
  616. time: timeKey,
  617. model: modelKey,
  618. quota: 0,
  619. count: 0,
  620. });
  621. }
  622. const existing = aggregatedData.get(key);
  623. existing.quota += item.quota;
  624. existing.count += item.count;
  625. });
  626. return aggregatedData;
  627. }, [dataExportDefaultTime]);
  628. const generateChartTimePoints = useCallback((aggregatedData, data) => {
  629. let chartTimePoints = Array.from(
  630. new Set([...aggregatedData.values()].map((d) => d.time)),
  631. );
  632. if (chartTimePoints.length < 7) {
  633. const lastTime = Math.max(...data.map((item) => item.created_at));
  634. const interval = getTimeInterval(dataExportDefaultTime, true);
  635. chartTimePoints = Array.from({ length: 7 }, (_, i) =>
  636. timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
  637. );
  638. }
  639. return chartTimePoints;
  640. }, [dataExportDefaultTime, getTimeInterval]);
  641. const updateChartData = useCallback((data) => {
  642. const processedData = processRawData(data);
  643. const { totalQuota, totalTimes, totalTokens, uniqueModels, timePoints, timeQuotaMap, timeTokensMap, timeCountMap } = processedData;
  644. const trendDataResult = calculateTrendData(timePoints, timeQuotaMap, timeTokensMap, timeCountMap);
  645. setTrendData(trendDataResult);
  646. const newModelColors = generateModelColors(uniqueModels);
  647. setModelColors(newModelColors);
  648. const aggregatedData = aggregateDataByTimeAndModel(data);
  649. const modelTotals = new Map();
  650. for (let [_, value] of aggregatedData) {
  651. updateMapValue(modelTotals, value.model, value.count);
  652. }
  653. const newPieData = Array.from(modelTotals).map(([model, count]) => ({
  654. type: model,
  655. value: count,
  656. })).sort((a, b) => b.value - a.value);
  657. const chartTimePoints = generateChartTimePoints(aggregatedData, data);
  658. let newLineData = [];
  659. chartTimePoints.forEach((time) => {
  660. let timeData = Array.from(uniqueModels).map((model) => {
  661. const key = `${time}-${model}`;
  662. const aggregated = aggregatedData.get(key);
  663. return {
  664. Time: time,
  665. Model: model,
  666. rawQuota: aggregated?.quota || 0,
  667. Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0,
  668. };
  669. });
  670. const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
  671. timeData.sort((a, b) => b.rawQuota - a.rawQuota);
  672. timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }));
  673. newLineData.push(...timeData);
  674. });
  675. newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
  676. updateChartSpec(
  677. setSpecPie,
  678. newPieData,
  679. `${t('总计')}:${renderNumber(totalTimes)}`,
  680. newModelColors,
  681. 'id0'
  682. );
  683. updateChartSpec(
  684. setSpecLine,
  685. newLineData,
  686. `${t('总计')}:${renderQuota(totalQuota, 2)}`,
  687. newModelColors,
  688. 'barData'
  689. );
  690. setPieData(newPieData);
  691. setLineData(newLineData);
  692. setConsumeQuota(totalQuota);
  693. setTimes(totalTimes);
  694. setConsumeTokens(totalTokens);
  695. }, [
  696. processRawData, calculateTrendData, generateModelColors, aggregateDataByTimeAndModel,
  697. generateChartTimePoints, updateChartSpec, updateMapValue, t
  698. ]);
  699. // ========== Hooks - Effects ==========
  700. useEffect(() => {
  701. getUserData();
  702. if (!initialized.current) {
  703. initVChartSemiTheme({
  704. isWatchingThemeSwitch: true,
  705. });
  706. initialized.current = true;
  707. initChart();
  708. }
  709. }, []);
  710. useEffect(() => {
  711. if (statusState?.status?.api_info) {
  712. setApiInfoData(statusState.status.api_info);
  713. }
  714. }, [statusState?.status?.api_info]);
  715. useEffect(() => {
  716. const timer = setTimeout(() => {
  717. checkApiScrollable();
  718. }, 100);
  719. return () => clearTimeout(timer);
  720. }, []);
  721. return (
  722. <div className="bg-gray-50 h-full">
  723. <div className="flex items-center justify-between mb-4">
  724. <h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
  725. <div className="flex gap-3">
  726. <IconButton
  727. icon={<IconSearch />}
  728. onClick={showSearchModal}
  729. className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
  730. />
  731. <IconButton
  732. icon={<IconRefresh />}
  733. onClick={refresh}
  734. loading={loading}
  735. className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
  736. />
  737. </div>
  738. </div>
  739. {/* 搜索条件Modal */}
  740. <Modal
  741. title={t('搜索条件')}
  742. visible={searchModalVisible}
  743. onOk={handleSearchConfirm}
  744. onCancel={handleCloseModal}
  745. closeOnEsc={true}
  746. size={isMobile() ? 'full-width' : 'small'}
  747. centered
  748. >
  749. <Form ref={formRef} layout='vertical' className="w-full">
  750. {createFormField(Form.DatePicker, {
  751. field: 'start_timestamp',
  752. label: t('起始时间'),
  753. initValue: start_timestamp,
  754. value: start_timestamp,
  755. type: 'dateTime',
  756. name: 'start_timestamp',
  757. onChange: (value) => handleInputChange(value, 'start_timestamp')
  758. })}
  759. {createFormField(Form.DatePicker, {
  760. field: 'end_timestamp',
  761. label: t('结束时间'),
  762. initValue: end_timestamp,
  763. value: end_timestamp,
  764. type: 'dateTime',
  765. name: 'end_timestamp',
  766. onChange: (value) => handleInputChange(value, 'end_timestamp')
  767. })}
  768. {createFormField(Form.Select, {
  769. field: 'data_export_default_time',
  770. label: t('时间粒度'),
  771. initValue: dataExportDefaultTime,
  772. placeholder: t('时间粒度'),
  773. name: 'data_export_default_time',
  774. optionList: timeOptions,
  775. onChange: (value) => handleInputChange(value, 'data_export_default_time')
  776. })}
  777. {isAdminUser && createFormField(Form.Input, {
  778. field: 'username',
  779. label: t('用户名称'),
  780. value: username,
  781. placeholder: t('可选值'),
  782. name: 'username',
  783. onChange: (value) => handleInputChange(value, 'username')
  784. })}
  785. </Form>
  786. </Modal>
  787. <Spin spinning={loading}>
  788. <div className="mb-4">
  789. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  790. {groupedStatsData.map((group, idx) => (
  791. <Card
  792. key={idx}
  793. {...CARD_PROPS}
  794. className={`${group.color} border-0 !rounded-2xl w-full`}
  795. title={group.title}
  796. >
  797. <div className="space-y-4">
  798. {group.items.map((item, itemIdx) => (
  799. <div
  800. key={itemIdx}
  801. className="flex items-center justify-between cursor-pointer"
  802. onClick={item.onClick}
  803. >
  804. <div className="flex items-center">
  805. <Avatar
  806. className="mr-3"
  807. size="small"
  808. color={item.avatarColor}
  809. >
  810. {item.icon}
  811. </Avatar>
  812. <div>
  813. <div className="text-xs text-gray-500">{item.title}</div>
  814. <div className="text-lg font-semibold">{item.value}</div>
  815. </div>
  816. </div>
  817. {item.trendData && item.trendData.length > 0 && (
  818. <div className="w-24 h-10">
  819. <VChart
  820. spec={getTrendSpec(item.trendData, item.trendColor)}
  821. option={CHART_CONFIG}
  822. />
  823. </div>
  824. )}
  825. </div>
  826. ))}
  827. </div>
  828. </Card>
  829. ))}
  830. </div>
  831. </div>
  832. <div className="mb-4">
  833. <div className={`grid grid-cols-1 gap-4 ${!statusState?.status?.self_use_mode_enabled ? 'lg:grid-cols-4' : ''}`}>
  834. <Card
  835. {...CARD_PROPS}
  836. className={`shadow-sm !rounded-2xl ${!statusState?.status?.self_use_mode_enabled ? 'lg:col-span-3' : ''}`}
  837. title={
  838. <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
  839. <div className={FLEX_CENTER_GAP2}>
  840. <PieChart size={16} />
  841. {t('模型数据分析')}
  842. </div>
  843. <Tabs
  844. type="button"
  845. activeKey={activeChartTab}
  846. onChange={setActiveChartTab}
  847. >
  848. <TabPane tab={
  849. <span>
  850. <IconHistogram />
  851. {t('消耗分布')}
  852. </span>
  853. } itemKey="1" />
  854. <TabPane tab={
  855. <span>
  856. <IconPieChart2Stroked />
  857. {t('调用次数分布')}
  858. </span>
  859. } itemKey="2" />
  860. </Tabs>
  861. </div>
  862. }
  863. >
  864. <div style={{ height: 400 }}>
  865. {activeChartTab === '1' ? (
  866. <VChart
  867. spec={spec_line}
  868. option={CHART_CONFIG}
  869. />
  870. ) : (
  871. <VChart
  872. spec={spec_pie}
  873. option={CHART_CONFIG}
  874. />
  875. )}
  876. </div>
  877. </Card>
  878. {!statusState?.status?.self_use_mode_enabled && (
  879. <Card
  880. {...CARD_PROPS}
  881. className="bg-gray-50 border-0 !rounded-2xl"
  882. title={
  883. <div className={FLEX_CENTER_GAP2}>
  884. <IconSearch size={16} />
  885. {t('API信息')}
  886. </div>
  887. }
  888. >
  889. <div className="api-info-container">
  890. <div
  891. ref={apiScrollRef}
  892. className="space-y-3 max-h-96 overflow-y-auto api-info-scroll"
  893. onScroll={handleApiScroll}
  894. >
  895. {apiInfoData.length > 0 ? (
  896. apiInfoData.map((api) => (
  897. <div key={api.id} className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
  898. <div className="flex-shrink-0 mr-3">
  899. <Avatar
  900. size="extra-small"
  901. color={api.color}
  902. >
  903. {api.route.substring(0, 2)}
  904. </Avatar>
  905. </div>
  906. <div className="flex-1">
  907. <div className="text-sm font-medium text-gray-900 mb-1 !font-bold flex items-center gap-2">
  908. <Tag
  909. prefixIcon={<Gauge size={12} />}
  910. size="small"
  911. color="white"
  912. shape='circle'
  913. onClick={() => handleSpeedTest(api.url)}
  914. className="cursor-pointer hover:opacity-80 text-xs"
  915. >
  916. {t('测速')}
  917. </Tag>
  918. {api.route}
  919. </div>
  920. <div
  921. className="text-xs !text-semi-color-primary font-mono break-all cursor-pointer hover:underline mb-1"
  922. onClick={() => handleCopyUrl(api.url)}
  923. >
  924. {api.url}
  925. </div>
  926. <div className="text-xs text-gray-500">
  927. {api.description}
  928. </div>
  929. </div>
  930. </div>
  931. ))
  932. ) : (
  933. <div className="flex justify-center items-center py-8">
  934. <Empty
  935. image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
  936. darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
  937. title={t('暂无API信息配置')}
  938. description={t('请联系管理员在系统设置中配置API信息')}
  939. style={{ padding: '12px' }}
  940. />
  941. </div>
  942. )}
  943. </div>
  944. <div
  945. className="api-info-fade-indicator"
  946. style={{ opacity: showApiScrollHint ? 1 : 0 }}
  947. />
  948. </div>
  949. </Card>
  950. )}
  951. </div>
  952. </div>
  953. </Spin>
  954. </div>
  955. );
  956. };
  957. export default Detail;