index.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859
  1. import React, { useContext, useEffect, useRef, useState } 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. } from '@douyinfe/semi-ui';
  13. import {
  14. IconRefresh,
  15. IconSearch,
  16. IconMoneyExchangeStroked,
  17. IconHistogram,
  18. IconRotate,
  19. IconCoinMoneyStroked,
  20. IconTextStroked,
  21. IconPulse,
  22. IconStopwatchStroked,
  23. IconTypograph,
  24. } from '@douyinfe/semi-icons';
  25. import { VChart } from '@visactor/react-vchart';
  26. import {
  27. API,
  28. isAdmin,
  29. isMobile,
  30. showError,
  31. timestamp2string,
  32. timestamp2string1,
  33. getQuotaWithUnit,
  34. modelColorMap,
  35. renderNumber,
  36. renderQuota,
  37. modelToColor
  38. } from '../../helpers';
  39. import { UserContext } from '../../context/User/index.js';
  40. import { useTranslation } from 'react-i18next';
  41. const Detail = (props) => {
  42. const { t } = useTranslation();
  43. const navigate = useNavigate();
  44. const formRef = useRef();
  45. let now = new Date();
  46. const [userState, userDispatch] = useContext(UserContext);
  47. const [inputs, setInputs] = useState({
  48. username: '',
  49. token_name: '',
  50. model_name: '',
  51. start_timestamp:
  52. localStorage.getItem('data_export_default_time') === 'hour'
  53. ? timestamp2string(now.getTime() / 1000 - 86400)
  54. : localStorage.getItem('data_export_default_time') === 'week'
  55. ? timestamp2string(now.getTime() / 1000 - 86400 * 30)
  56. : timestamp2string(now.getTime() / 1000 - 86400 * 7),
  57. end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
  58. channel: '',
  59. data_export_default_time: '',
  60. });
  61. const { username, model_name, start_timestamp, end_timestamp, channel } =
  62. inputs;
  63. const isAdminUser = isAdmin();
  64. const initialized = useRef(false);
  65. const [loading, setLoading] = useState(false);
  66. const [quotaData, setQuotaData] = useState([]);
  67. const [consumeQuota, setConsumeQuota] = useState(0);
  68. const [consumeTokens, setConsumeTokens] = useState(0);
  69. const [times, setTimes] = useState(0);
  70. const [dataExportDefaultTime, setDataExportDefaultTime] = useState(
  71. localStorage.getItem('data_export_default_time') || 'hour',
  72. );
  73. const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
  74. const [lineData, setLineData] = useState([]);
  75. const [searchModalVisible, setSearchModalVisible] = useState(false);
  76. const [spec_pie, setSpecPie] = useState({
  77. type: 'pie',
  78. data: [
  79. {
  80. id: 'id0',
  81. values: pieData,
  82. },
  83. ],
  84. outerRadius: 0.8,
  85. innerRadius: 0.5,
  86. padAngle: 0.6,
  87. valueField: 'value',
  88. categoryField: 'type',
  89. pie: {
  90. style: {
  91. cornerRadius: 10,
  92. },
  93. state: {
  94. hover: {
  95. outerRadius: 0.85,
  96. stroke: '#000',
  97. lineWidth: 1,
  98. },
  99. selected: {
  100. outerRadius: 0.85,
  101. stroke: '#000',
  102. lineWidth: 1,
  103. },
  104. },
  105. },
  106. title: {
  107. visible: true,
  108. text: t('模型调用次数占比'),
  109. subtext: `${t('总计')}:${renderNumber(times)}`,
  110. },
  111. legends: {
  112. visible: true,
  113. orient: 'left',
  114. },
  115. label: {
  116. visible: true,
  117. },
  118. tooltip: {
  119. mark: {
  120. content: [
  121. {
  122. key: (datum) => datum['type'],
  123. value: (datum) => renderNumber(datum['value']),
  124. },
  125. ],
  126. },
  127. },
  128. color: {
  129. specified: modelColorMap,
  130. },
  131. });
  132. const [spec_line, setSpecLine] = useState({
  133. type: 'bar',
  134. data: [
  135. {
  136. id: 'barData',
  137. values: lineData,
  138. },
  139. ],
  140. xField: 'Time',
  141. yField: 'Usage',
  142. seriesField: 'Model',
  143. stack: true,
  144. legends: {
  145. visible: true,
  146. selectMode: 'single',
  147. },
  148. title: {
  149. visible: true,
  150. text: t('模型消耗分布'),
  151. subtext: `${t('总计')}:${renderQuota(consumeQuota, 2)}`,
  152. },
  153. bar: {
  154. state: {
  155. hover: {
  156. stroke: '#000',
  157. lineWidth: 1,
  158. },
  159. },
  160. },
  161. tooltip: {
  162. mark: {
  163. content: [
  164. {
  165. key: (datum) => datum['Model'],
  166. value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
  167. },
  168. ],
  169. },
  170. dimension: {
  171. content: [
  172. {
  173. key: (datum) => datum['Model'],
  174. value: (datum) => datum['rawQuota'] || 0,
  175. },
  176. ],
  177. updateContent: (array) => {
  178. array.sort((a, b) => b.value - a.value);
  179. let sum = 0;
  180. for (let i = 0; i < array.length; i++) {
  181. if (array[i].key == '其他') {
  182. continue;
  183. }
  184. let value = parseFloat(array[i].value);
  185. if (isNaN(value)) {
  186. value = 0;
  187. }
  188. if (array[i].datum && array[i].datum.TimeSum) {
  189. sum = array[i].datum.TimeSum;
  190. }
  191. array[i].value = renderQuota(value, 4);
  192. }
  193. array.unshift({
  194. key: t('总计'),
  195. value: renderQuota(sum, 4),
  196. });
  197. return array;
  198. },
  199. },
  200. },
  201. color: {
  202. specified: modelColorMap,
  203. },
  204. });
  205. // 添加一个新的状态来存储模型-颜色映射
  206. const [modelColors, setModelColors] = useState({});
  207. // 添加趋势数据状态
  208. const [trendData, setTrendData] = useState({
  209. balance: [],
  210. usedQuota: [],
  211. requestCount: [],
  212. times: [],
  213. consumeQuota: [],
  214. tokens: [],
  215. rpm: [],
  216. tpm: []
  217. });
  218. // 迷你趋势图配置
  219. const getTrendSpec = (data, color) => ({
  220. type: 'line',
  221. data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
  222. xField: 'x',
  223. yField: 'y',
  224. height: 40,
  225. width: 100,
  226. axes: [
  227. {
  228. orient: 'bottom',
  229. visible: false
  230. },
  231. {
  232. orient: 'left',
  233. visible: false
  234. }
  235. ],
  236. padding: 0,
  237. autoFit: false,
  238. legends: { visible: false },
  239. tooltip: { visible: false },
  240. crosshair: { visible: false },
  241. line: {
  242. style: {
  243. stroke: color,
  244. lineWidth: 2
  245. }
  246. },
  247. point: {
  248. visible: false
  249. },
  250. background: {
  251. fill: 'transparent'
  252. }
  253. });
  254. // 显示搜索Modal
  255. const showSearchModal = () => {
  256. setSearchModalVisible(true);
  257. };
  258. // 关闭搜索Modal
  259. const handleCloseModal = () => {
  260. setSearchModalVisible(false);
  261. };
  262. // 搜索Modal确认按钮
  263. const handleSearchConfirm = () => {
  264. refresh();
  265. setSearchModalVisible(false);
  266. };
  267. const handleInputChange = (value, name) => {
  268. if (name === 'data_export_default_time') {
  269. setDataExportDefaultTime(value);
  270. return;
  271. }
  272. setInputs((inputs) => ({ ...inputs, [name]: value }));
  273. };
  274. const loadQuotaData = async () => {
  275. setLoading(true);
  276. try {
  277. let url = '';
  278. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  279. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  280. if (isAdminUser) {
  281. url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  282. } else {
  283. url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  284. }
  285. const res = await API.get(url);
  286. const { success, message, data } = res.data;
  287. if (success) {
  288. setQuotaData(data);
  289. if (data.length === 0) {
  290. data.push({
  291. count: 0,
  292. model_name: '无数据',
  293. quota: 0,
  294. created_at: now.getTime() / 1000,
  295. });
  296. }
  297. // sort created_at
  298. data.sort((a, b) => a.created_at - b.created_at);
  299. updateChartData(data);
  300. } else {
  301. showError(message);
  302. }
  303. } finally {
  304. setLoading(false);
  305. }
  306. };
  307. const refresh = async () => {
  308. await loadQuotaData();
  309. };
  310. const initChart = async () => {
  311. await loadQuotaData();
  312. };
  313. const updateChartData = (data) => {
  314. let newPieData = [];
  315. let newLineData = [];
  316. let totalQuota = 0;
  317. let totalTimes = 0;
  318. let uniqueModels = new Set();
  319. let totalTokens = 0;
  320. // 趋势数据处理
  321. let timePoints = [];
  322. let timeQuotaMap = new Map();
  323. let timeTokensMap = new Map();
  324. let timeCountMap = new Map();
  325. // 收集所有唯一的模型名称和时间点
  326. data.forEach((item) => {
  327. uniqueModels.add(item.model_name);
  328. totalTokens += item.token_used;
  329. totalQuota += item.quota;
  330. totalTimes += item.count;
  331. // 记录时间点
  332. const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
  333. if (!timePoints.includes(timeKey)) {
  334. timePoints.push(timeKey);
  335. }
  336. // 按时间点累加数据
  337. if (!timeQuotaMap.has(timeKey)) {
  338. timeQuotaMap.set(timeKey, 0);
  339. timeTokensMap.set(timeKey, 0);
  340. timeCountMap.set(timeKey, 0);
  341. }
  342. timeQuotaMap.set(timeKey, timeQuotaMap.get(timeKey) + item.quota);
  343. timeTokensMap.set(timeKey, timeTokensMap.get(timeKey) + item.token_used);
  344. timeCountMap.set(timeKey, timeCountMap.get(timeKey) + item.count);
  345. });
  346. // 确保时间点有序
  347. timePoints.sort();
  348. // 生成趋势数据
  349. const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0);
  350. const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0);
  351. const countTrend = timePoints.map(time => timeCountMap.get(time) || 0);
  352. // 计算RPM和TPM趋势
  353. const rpmTrend = [];
  354. const tpmTrend = [];
  355. if (timePoints.length >= 2) {
  356. const interval = dataExportDefaultTime === 'hour'
  357. ? 60 // 分钟/小时
  358. : dataExportDefaultTime === 'day'
  359. ? 1440 // 分钟/天
  360. : 10080; // 分钟/周
  361. for (let i = 0; i < timePoints.length; i++) {
  362. rpmTrend.push(timeCountMap.get(timePoints[i]) / interval);
  363. tpmTrend.push(timeTokensMap.get(timePoints[i]) / interval);
  364. }
  365. }
  366. // 更新趋势数据状态
  367. setTrendData({
  368. // 账户数据不在API返回中,保持空数组
  369. balance: [],
  370. usedQuota: [],
  371. // 使用统计
  372. requestCount: [], // 没有总请求次数趋势数据
  373. times: countTrend,
  374. // 资源消耗
  375. consumeQuota: quotaTrend,
  376. tokens: tokensTrend,
  377. // 性能指标
  378. rpm: rpmTrend,
  379. tpm: tpmTrend
  380. });
  381. // 处理颜色映射
  382. const newModelColors = {};
  383. Array.from(uniqueModels).forEach((modelName) => {
  384. newModelColors[modelName] =
  385. modelColorMap[modelName] ||
  386. modelColors[modelName] ||
  387. modelToColor(modelName);
  388. });
  389. setModelColors(newModelColors);
  390. // 按时间和模型聚合数据
  391. let aggregatedData = new Map();
  392. data.forEach((item) => {
  393. const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
  394. const modelKey = item.model_name;
  395. const key = `${timeKey}-${modelKey}`;
  396. if (!aggregatedData.has(key)) {
  397. aggregatedData.set(key, {
  398. time: timeKey,
  399. model: modelKey,
  400. quota: 0,
  401. count: 0,
  402. });
  403. }
  404. const existing = aggregatedData.get(key);
  405. existing.quota += item.quota;
  406. existing.count += item.count;
  407. });
  408. // 处理饼图数据
  409. let modelTotals = new Map();
  410. for (let [_, value] of aggregatedData) {
  411. if (!modelTotals.has(value.model)) {
  412. modelTotals.set(value.model, 0);
  413. }
  414. modelTotals.set(value.model, modelTotals.get(value.model) + value.count);
  415. }
  416. newPieData = Array.from(modelTotals).map(([model, count]) => ({
  417. type: model,
  418. value: count,
  419. }));
  420. // 生成时间点序列
  421. let chartTimePoints = Array.from(
  422. new Set([...aggregatedData.values()].map((d) => d.time)),
  423. );
  424. if (chartTimePoints.length < 7) {
  425. const lastTime = Math.max(...data.map((item) => item.created_at));
  426. const interval =
  427. dataExportDefaultTime === 'hour'
  428. ? 3600
  429. : dataExportDefaultTime === 'day'
  430. ? 86400
  431. : 604800;
  432. chartTimePoints = Array.from({ length: 7 }, (_, i) =>
  433. timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
  434. );
  435. }
  436. // 生成柱状图数据
  437. chartTimePoints.forEach((time) => {
  438. // 为每个时间点收集所有模型的数据
  439. let timeData = Array.from(uniqueModels).map((model) => {
  440. const key = `${time}-${model}`;
  441. const aggregated = aggregatedData.get(key);
  442. return {
  443. Time: time,
  444. Model: model,
  445. rawQuota: aggregated?.quota || 0,
  446. Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0,
  447. };
  448. });
  449. // 计算该时间点的总计
  450. const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
  451. // 按照 rawQuota 从大到小排序
  452. timeData.sort((a, b) => b.rawQuota - a.rawQuota);
  453. // 为每个数据点添加该时间的总计
  454. timeData = timeData.map((item) => ({
  455. ...item,
  456. TimeSum: timeSum,
  457. }));
  458. // 将排序后的数据添加到 newLineData
  459. newLineData.push(...timeData);
  460. });
  461. // 排序
  462. newPieData.sort((a, b) => b.value - a.value);
  463. newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
  464. // 更新图表配置和数据
  465. setSpecPie((prev) => ({
  466. ...prev,
  467. data: [{ id: 'id0', values: newPieData }],
  468. title: {
  469. ...prev.title,
  470. subtext: `${t('总计')}:${renderNumber(totalTimes)}`,
  471. },
  472. color: {
  473. specified: newModelColors,
  474. },
  475. }));
  476. setSpecLine((prev) => ({
  477. ...prev,
  478. data: [{ id: 'barData', values: newLineData }],
  479. title: {
  480. ...prev.title,
  481. subtext: `${t('总计')}:${renderQuota(totalQuota, 2)}`,
  482. },
  483. color: {
  484. specified: newModelColors,
  485. },
  486. }));
  487. setPieData(newPieData);
  488. setLineData(newLineData);
  489. setConsumeQuota(totalQuota);
  490. setTimes(totalTimes);
  491. setConsumeTokens(totalTokens);
  492. };
  493. const getUserData = async () => {
  494. let res = await API.get(`/api/user/self`);
  495. const { success, message, data } = res.data;
  496. if (success) {
  497. userDispatch({ type: 'login', payload: data });
  498. } else {
  499. showError(message);
  500. }
  501. };
  502. useEffect(() => {
  503. getUserData();
  504. if (!initialized.current) {
  505. initVChartSemiTheme({
  506. isWatchingThemeSwitch: true,
  507. });
  508. initialized.current = true;
  509. initChart();
  510. }
  511. }, []);
  512. // 数据卡片信息
  513. const groupedStatsData = [
  514. {
  515. title: (
  516. <div className="flex items-center gap-2">
  517. <Wallet size={16} />
  518. {t('账户数据')}
  519. </div>
  520. ),
  521. color: 'bg-blue-50',
  522. items: [
  523. {
  524. title: t('当前余额'),
  525. value: renderQuota(userState?.user?.quota),
  526. icon: <IconMoneyExchangeStroked size="large" />,
  527. avatarColor: 'blue',
  528. onClick: () => navigate('/console/topup'),
  529. trendData: [], // 当前余额没有趋势数据
  530. trendColor: '#3b82f6'
  531. },
  532. {
  533. title: t('历史消耗'),
  534. value: renderQuota(userState?.user?.used_quota),
  535. icon: <IconHistogram size="large" />,
  536. avatarColor: 'purple',
  537. trendData: [], // 历史消耗没有趋势数据
  538. trendColor: '#8b5cf6'
  539. }
  540. ]
  541. },
  542. {
  543. title: (
  544. <div className="flex items-center gap-2">
  545. <Activity size={16} />
  546. {t('使用统计')}
  547. </div>
  548. ),
  549. color: 'bg-green-50',
  550. items: [
  551. {
  552. title: t('请求次数'),
  553. value: userState.user?.request_count,
  554. icon: <IconRotate size="large" />,
  555. avatarColor: 'green',
  556. trendData: [], // 请求次数没有趋势数据
  557. trendColor: '#10b981'
  558. },
  559. {
  560. title: t('统计次数'),
  561. value: times,
  562. icon: <IconPulse size="large" />,
  563. avatarColor: 'cyan',
  564. trendData: trendData.times,
  565. trendColor: '#06b6d4'
  566. }
  567. ]
  568. },
  569. {
  570. title: (
  571. <div className="flex items-center gap-2">
  572. <Zap size={16} />
  573. {t('资源消耗')}
  574. </div>
  575. ),
  576. color: 'bg-yellow-50',
  577. items: [
  578. {
  579. title: t('统计额度'),
  580. value: renderQuota(consumeQuota),
  581. icon: <IconCoinMoneyStroked size="large" />,
  582. avatarColor: 'yellow',
  583. trendData: trendData.consumeQuota,
  584. trendColor: '#f59e0b'
  585. },
  586. {
  587. title: t('统计Tokens'),
  588. value: isNaN(consumeTokens) ? 0 : consumeTokens,
  589. icon: <IconTextStroked size="large" />,
  590. avatarColor: 'pink',
  591. trendData: trendData.tokens,
  592. trendColor: '#ec4899'
  593. }
  594. ]
  595. },
  596. {
  597. title: (
  598. <div className="flex items-center gap-2">
  599. <Gauge size={16} />
  600. {t('性能指标')}
  601. </div>
  602. ),
  603. color: 'bg-indigo-50',
  604. items: [
  605. {
  606. title: t('平均RPM'),
  607. value: (
  608. times /
  609. ((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000)
  610. ).toFixed(3),
  611. icon: <IconStopwatchStroked size="large" />,
  612. avatarColor: 'indigo',
  613. trendData: trendData.rpm,
  614. trendColor: '#6366f1'
  615. },
  616. {
  617. title: t('平均TPM'),
  618. value: (() => {
  619. const tpm = consumeTokens /
  620. ((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000);
  621. return isNaN(tpm) ? '0' : tpm.toFixed(3);
  622. })(),
  623. icon: <IconTypograph size="large" />,
  624. avatarColor: 'orange',
  625. trendData: trendData.tpm,
  626. trendColor: '#f97316'
  627. }
  628. ]
  629. }
  630. ];
  631. // 获取问候语
  632. const getGreeting = () => {
  633. const hours = new Date().getHours();
  634. let greeting = '';
  635. if (hours >= 5 && hours < 12) {
  636. greeting = t('早上好');
  637. } else if (hours >= 12 && hours < 14) {
  638. greeting = t('中午好');
  639. } else if (hours >= 14 && hours < 18) {
  640. greeting = t('下午好');
  641. } else {
  642. greeting = t('晚上好');
  643. }
  644. const username = userState?.user?.username || '';
  645. return `👋${greeting},${username}`;
  646. };
  647. return (
  648. <div className="bg-gray-50 h-full">
  649. <div className="flex items-center justify-between mb-4">
  650. <h2 className="text-2xl font-semibold text-gray-800">{getGreeting()}</h2>
  651. <div className="flex gap-3">
  652. <IconButton
  653. icon={<IconSearch />}
  654. onClick={showSearchModal}
  655. className="bg-green-500 text-white hover:bg-green-600 !rounded-full"
  656. />
  657. <IconButton
  658. icon={<IconRefresh />}
  659. onClick={refresh}
  660. loading={loading}
  661. className="bg-blue-500 text-white hover:bg-blue-600 !rounded-full"
  662. />
  663. </div>
  664. </div>
  665. {/* 搜索条件Modal */}
  666. <Modal
  667. title={t('搜索条件')}
  668. visible={searchModalVisible}
  669. onOk={handleSearchConfirm}
  670. onCancel={handleCloseModal}
  671. closeOnEsc={true}
  672. size={isMobile() ? 'full-width' : 'small'}
  673. centered
  674. >
  675. <Form ref={formRef} layout='vertical' className="w-full">
  676. <Form.DatePicker
  677. field='start_timestamp'
  678. label={t('起始时间')}
  679. className="w-full mb-2 !rounded-lg"
  680. initValue={start_timestamp}
  681. value={start_timestamp}
  682. type='dateTime'
  683. name='start_timestamp'
  684. size='large'
  685. onChange={(value) => handleInputChange(value, 'start_timestamp')}
  686. />
  687. <Form.DatePicker
  688. field='end_timestamp'
  689. label={t('结束时间')}
  690. className="w-full mb-2 !rounded-lg"
  691. initValue={end_timestamp}
  692. value={end_timestamp}
  693. type='dateTime'
  694. name='end_timestamp'
  695. size='large'
  696. onChange={(value) => handleInputChange(value, 'end_timestamp')}
  697. />
  698. <Form.Select
  699. field='data_export_default_time'
  700. label={t('时间粒度')}
  701. className="w-full mb-2 !rounded-lg"
  702. initValue={dataExportDefaultTime}
  703. placeholder={t('时间粒度')}
  704. name='data_export_default_time'
  705. size='large'
  706. optionList={[
  707. { label: t('小时'), value: 'hour' },
  708. { label: t('天'), value: 'day' },
  709. { label: t('周'), value: 'week' },
  710. ]}
  711. onChange={(value) => handleInputChange(value, 'data_export_default_time')}
  712. />
  713. {isAdminUser && (
  714. <Form.Input
  715. field='username'
  716. label={t('用户名称')}
  717. className="w-full mb-2 !rounded-lg"
  718. value={username}
  719. placeholder={t('可选值')}
  720. name='username'
  721. size='large'
  722. onChange={(value) => handleInputChange(value, 'username')}
  723. />
  724. )}
  725. </Form>
  726. </Modal>
  727. <Spin spinning={loading}>
  728. <div className="mb-4">
  729. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  730. {groupedStatsData.map((group, idx) => (
  731. <Card
  732. key={idx}
  733. shadows='always'
  734. bordered={false}
  735. className={`${group.color} border-0 !rounded-2xl w-full`}
  736. headerLine={true}
  737. title={group.title}
  738. >
  739. <div className="space-y-4">
  740. {group.items.map((item, itemIdx) => (
  741. <div
  742. key={itemIdx}
  743. className="flex items-center justify-between cursor-pointer"
  744. onClick={item.onClick}
  745. >
  746. <div className="flex items-center">
  747. <Avatar
  748. className="mr-3"
  749. size="small"
  750. color={item.avatarColor}
  751. >
  752. {item.icon}
  753. </Avatar>
  754. <div>
  755. <div className="text-xs text-gray-500">{item.title}</div>
  756. <div className="text-lg font-semibold">{item.value}</div>
  757. </div>
  758. </div>
  759. {item.trendData && item.trendData.length > 0 && (
  760. <div className="w-24 h-10">
  761. <VChart
  762. spec={getTrendSpec(item.trendData, item.trendColor)}
  763. option={{ mode: 'desktop-browser' }}
  764. />
  765. </div>
  766. )}
  767. </div>
  768. ))}
  769. </div>
  770. </Card>
  771. ))}
  772. </div>
  773. </div>
  774. <div className="grid grid-cols-1 lg:grid-cols-1 gap-6 mb-6">
  775. <Card
  776. shadows='always'
  777. bordered={false}
  778. className="shadow-sm !rounded-2xl"
  779. headerLine={true}
  780. title={
  781. <div className="flex items-center gap-2">
  782. <PieChart size={16} />
  783. {t('模型数据分析')}
  784. </div>
  785. }
  786. >
  787. <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
  788. <div style={{ height: 400 }}>
  789. <VChart
  790. spec={spec_line}
  791. option={{ mode: 'desktop-browser' }}
  792. />
  793. </div>
  794. <div style={{ height: 400 }}>
  795. <VChart
  796. spec={spec_pie}
  797. option={{ mode: 'desktop-browser' }}
  798. />
  799. </div>
  800. </div>
  801. </Card>
  802. </div>
  803. </Spin>
  804. </div>
  805. );
  806. };
  807. export default Detail;