index.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. import React, { useContext, useEffect, useRef, useState } from 'react';
  2. import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
  3. import {
  4. Card,
  5. Form,
  6. Spin,
  7. Typography,
  8. IconButton,
  9. Modal,
  10. Avatar,
  11. } from '@douyinfe/semi-ui';
  12. import {
  13. IconRefresh,
  14. IconSearch,
  15. IconMoneyExchangeStroked,
  16. IconHistogram,
  17. IconRotate,
  18. IconCoinMoneyStroked,
  19. IconTextStroked,
  20. IconPulse,
  21. IconStopwatchStroked,
  22. IconTypograph,
  23. } from '@douyinfe/semi-icons';
  24. import { VChart } from '@visactor/react-vchart';
  25. import {
  26. API,
  27. isAdmin,
  28. isMobile,
  29. showError,
  30. timestamp2string,
  31. timestamp2string1,
  32. } from '../../helpers';
  33. import {
  34. getQuotaWithUnit,
  35. modelColorMap,
  36. renderNumber,
  37. renderQuota,
  38. modelToColor,
  39. } from '../../helpers/render';
  40. import { UserContext } from '../../context/User/index.js';
  41. import { StyleContext } from '../../context/Style/index.js';
  42. import { useTranslation } from 'react-i18next';
  43. const Detail = (props) => {
  44. const { t } = useTranslation();
  45. const { Text } = Typography;
  46. const formRef = useRef();
  47. let now = new Date();
  48. const [userState, userDispatch] = useContext(UserContext);
  49. const [styleState, styleDispatch] = useContext(StyleContext);
  50. const [inputs, setInputs] = useState({
  51. username: '',
  52. token_name: '',
  53. model_name: '',
  54. start_timestamp:
  55. localStorage.getItem('data_export_default_time') === 'hour'
  56. ? timestamp2string(now.getTime() / 1000 - 86400)
  57. : localStorage.getItem('data_export_default_time') === 'week'
  58. ? timestamp2string(now.getTime() / 1000 - 86400 * 30)
  59. : timestamp2string(now.getTime() / 1000 - 86400 * 7),
  60. end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
  61. channel: '',
  62. data_export_default_time: '',
  63. });
  64. const { username, model_name, start_timestamp, end_timestamp, channel } =
  65. inputs;
  66. const isAdminUser = isAdmin();
  67. const initialized = useRef(false);
  68. const [loading, setLoading] = useState(false);
  69. const [quotaData, setQuotaData] = useState([]);
  70. const [consumeQuota, setConsumeQuota] = useState(0);
  71. const [consumeTokens, setConsumeTokens] = useState(0);
  72. const [times, setTimes] = useState(0);
  73. const [dataExportDefaultTime, setDataExportDefaultTime] = useState(
  74. localStorage.getItem('data_export_default_time') || 'hour',
  75. );
  76. const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
  77. const [lineData, setLineData] = useState([]);
  78. const [searchModalVisible, setSearchModalVisible] = useState(false);
  79. const [spec_pie, setSpecPie] = useState({
  80. type: 'pie',
  81. data: [
  82. {
  83. id: 'id0',
  84. values: pieData,
  85. },
  86. ],
  87. outerRadius: 0.8,
  88. innerRadius: 0.5,
  89. padAngle: 0.6,
  90. valueField: 'value',
  91. categoryField: 'type',
  92. pie: {
  93. style: {
  94. cornerRadius: 10,
  95. },
  96. state: {
  97. hover: {
  98. outerRadius: 0.85,
  99. stroke: '#000',
  100. lineWidth: 1,
  101. },
  102. selected: {
  103. outerRadius: 0.85,
  104. stroke: '#000',
  105. lineWidth: 1,
  106. },
  107. },
  108. },
  109. title: {
  110. visible: true,
  111. text: t('模型调用次数占比'),
  112. subtext: `${t('总计')}:${renderNumber(times)}`,
  113. },
  114. legends: {
  115. visible: true,
  116. orient: 'left',
  117. },
  118. label: {
  119. visible: true,
  120. },
  121. tooltip: {
  122. mark: {
  123. content: [
  124. {
  125. key: (datum) => datum['type'],
  126. value: (datum) => renderNumber(datum['value']),
  127. },
  128. ],
  129. },
  130. },
  131. color: {
  132. specified: modelColorMap,
  133. },
  134. });
  135. const [spec_line, setSpecLine] = useState({
  136. type: 'bar',
  137. data: [
  138. {
  139. id: 'barData',
  140. values: lineData,
  141. },
  142. ],
  143. xField: 'Time',
  144. yField: 'Usage',
  145. seriesField: 'Model',
  146. stack: true,
  147. legends: {
  148. visible: true,
  149. selectMode: 'single',
  150. },
  151. title: {
  152. visible: true,
  153. text: t('模型消耗分布'),
  154. subtext: `${t('总计')}:${renderQuota(consumeQuota, 2)}`,
  155. },
  156. bar: {
  157. state: {
  158. hover: {
  159. stroke: '#000',
  160. lineWidth: 1,
  161. },
  162. },
  163. },
  164. tooltip: {
  165. mark: {
  166. content: [
  167. {
  168. key: (datum) => datum['Model'],
  169. value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
  170. },
  171. ],
  172. },
  173. dimension: {
  174. content: [
  175. {
  176. key: (datum) => datum['Model'],
  177. value: (datum) => datum['rawQuota'] || 0,
  178. },
  179. ],
  180. updateContent: (array) => {
  181. array.sort((a, b) => b.value - a.value);
  182. let sum = 0;
  183. for (let i = 0; i < array.length; i++) {
  184. if (array[i].key == '其他') {
  185. continue;
  186. }
  187. let value = parseFloat(array[i].value);
  188. if (isNaN(value)) {
  189. value = 0;
  190. }
  191. if (array[i].datum && array[i].datum.TimeSum) {
  192. sum = array[i].datum.TimeSum;
  193. }
  194. array[i].value = renderQuota(value, 4);
  195. }
  196. array.unshift({
  197. key: t('总计'),
  198. value: renderQuota(sum, 4),
  199. });
  200. return array;
  201. },
  202. },
  203. },
  204. color: {
  205. specified: modelColorMap,
  206. },
  207. });
  208. // 添加一个新的状态来存储模型-颜色映射
  209. const [modelColors, setModelColors] = useState({});
  210. // 显示搜索Modal
  211. const showSearchModal = () => {
  212. setSearchModalVisible(true);
  213. };
  214. // 关闭搜索Modal
  215. const handleCloseModal = () => {
  216. setSearchModalVisible(false);
  217. };
  218. // 搜索Modal确认按钮
  219. const handleSearchConfirm = () => {
  220. refresh();
  221. setSearchModalVisible(false);
  222. };
  223. const handleInputChange = (value, name) => {
  224. if (name === 'data_export_default_time') {
  225. setDataExportDefaultTime(value);
  226. return;
  227. }
  228. setInputs((inputs) => ({ ...inputs, [name]: value }));
  229. };
  230. const loadQuotaData = async () => {
  231. setLoading(true);
  232. try {
  233. let url = '';
  234. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  235. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  236. if (isAdminUser) {
  237. url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  238. } else {
  239. url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
  240. }
  241. const res = await API.get(url);
  242. const { success, message, data } = res.data;
  243. if (success) {
  244. setQuotaData(data);
  245. if (data.length === 0) {
  246. data.push({
  247. count: 0,
  248. model_name: '无数据',
  249. quota: 0,
  250. created_at: now.getTime() / 1000,
  251. });
  252. }
  253. // sort created_at
  254. data.sort((a, b) => a.created_at - b.created_at);
  255. updateChartData(data);
  256. } else {
  257. showError(message);
  258. }
  259. } finally {
  260. setLoading(false);
  261. }
  262. };
  263. const refresh = async () => {
  264. await loadQuotaData();
  265. };
  266. const initChart = async () => {
  267. await loadQuotaData();
  268. };
  269. const updateChartData = (data) => {
  270. let newPieData = [];
  271. let newLineData = [];
  272. let totalQuota = 0;
  273. let totalTimes = 0;
  274. let uniqueModels = new Set();
  275. let totalTokens = 0;
  276. // 收集所有唯一的模型名称
  277. data.forEach((item) => {
  278. uniqueModels.add(item.model_name);
  279. totalTokens += item.token_used;
  280. totalQuota += item.quota;
  281. totalTimes += item.count;
  282. });
  283. // 处理颜色映射
  284. const newModelColors = {};
  285. Array.from(uniqueModels).forEach((modelName) => {
  286. newModelColors[modelName] =
  287. modelColorMap[modelName] ||
  288. modelColors[modelName] ||
  289. modelToColor(modelName);
  290. });
  291. setModelColors(newModelColors);
  292. // 按时间和模型聚合数据
  293. let aggregatedData = new Map();
  294. data.forEach((item) => {
  295. const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
  296. const modelKey = item.model_name;
  297. const key = `${timeKey}-${modelKey}`;
  298. if (!aggregatedData.has(key)) {
  299. aggregatedData.set(key, {
  300. time: timeKey,
  301. model: modelKey,
  302. quota: 0,
  303. count: 0,
  304. });
  305. }
  306. const existing = aggregatedData.get(key);
  307. existing.quota += item.quota;
  308. existing.count += item.count;
  309. });
  310. // 处理饼图数据
  311. let modelTotals = new Map();
  312. for (let [_, value] of aggregatedData) {
  313. if (!modelTotals.has(value.model)) {
  314. modelTotals.set(value.model, 0);
  315. }
  316. modelTotals.set(value.model, modelTotals.get(value.model) + value.count);
  317. }
  318. newPieData = Array.from(modelTotals).map(([model, count]) => ({
  319. type: model,
  320. value: count,
  321. }));
  322. // 生成时间点序列
  323. let timePoints = Array.from(
  324. new Set([...aggregatedData.values()].map((d) => d.time)),
  325. );
  326. if (timePoints.length < 7) {
  327. const lastTime = Math.max(...data.map((item) => item.created_at));
  328. const interval =
  329. dataExportDefaultTime === 'hour'
  330. ? 3600
  331. : dataExportDefaultTime === 'day'
  332. ? 86400
  333. : 604800;
  334. timePoints = Array.from({ length: 7 }, (_, i) =>
  335. timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
  336. );
  337. }
  338. // 生成柱状图数据
  339. timePoints.forEach((time) => {
  340. // 为每个时间点收集所有模型的数据
  341. let timeData = Array.from(uniqueModels).map((model) => {
  342. const key = `${time}-${model}`;
  343. const aggregated = aggregatedData.get(key);
  344. return {
  345. Time: time,
  346. Model: model,
  347. rawQuota: aggregated?.quota || 0,
  348. Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0,
  349. };
  350. });
  351. // 计算该时间点的总计
  352. const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
  353. // 按照 rawQuota 从大到小排序
  354. timeData.sort((a, b) => b.rawQuota - a.rawQuota);
  355. // 为每个数据点添加该时间的总计
  356. timeData = timeData.map((item) => ({
  357. ...item,
  358. TimeSum: timeSum,
  359. }));
  360. // 将排序后的数据添加到 newLineData
  361. newLineData.push(...timeData);
  362. });
  363. // 排序
  364. newPieData.sort((a, b) => b.value - a.value);
  365. newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
  366. // 更新图表配置和数据
  367. setSpecPie((prev) => ({
  368. ...prev,
  369. data: [{ id: 'id0', values: newPieData }],
  370. title: {
  371. ...prev.title,
  372. subtext: `${t('总计')}:${renderNumber(totalTimes)}`,
  373. },
  374. color: {
  375. specified: newModelColors,
  376. },
  377. }));
  378. setSpecLine((prev) => ({
  379. ...prev,
  380. data: [{ id: 'barData', values: newLineData }],
  381. title: {
  382. ...prev.title,
  383. subtext: `${t('总计')}:${renderQuota(totalQuota, 2)}`,
  384. },
  385. color: {
  386. specified: newModelColors,
  387. },
  388. }));
  389. setPieData(newPieData);
  390. setLineData(newLineData);
  391. setConsumeQuota(totalQuota);
  392. setTimes(totalTimes);
  393. setConsumeTokens(totalTokens);
  394. };
  395. const getUserData = async () => {
  396. let res = await API.get(`/api/user/self`);
  397. const { success, message, data } = res.data;
  398. if (success) {
  399. userDispatch({ type: 'login', payload: data });
  400. } else {
  401. showError(message);
  402. }
  403. };
  404. useEffect(() => {
  405. getUserData();
  406. if (!initialized.current) {
  407. initVChartSemiTheme({
  408. isWatchingThemeSwitch: true,
  409. });
  410. initialized.current = true;
  411. initChart();
  412. }
  413. }, []);
  414. // 数据卡片信息
  415. const statsData = [
  416. {
  417. title: t('当前余额'),
  418. value: renderQuota(userState?.user?.quota),
  419. icon: <IconMoneyExchangeStroked size="large" />,
  420. color: 'bg-blue-50',
  421. avatarColor: 'blue',
  422. },
  423. {
  424. title: t('历史消耗'),
  425. value: renderQuota(userState?.user?.used_quota),
  426. icon: <IconHistogram size="large" />,
  427. color: 'bg-purple-50',
  428. avatarColor: 'purple',
  429. },
  430. {
  431. title: t('请求次数'),
  432. value: userState.user?.request_count,
  433. icon: <IconRotate size="large" />,
  434. color: 'bg-green-50',
  435. avatarColor: 'green',
  436. },
  437. {
  438. title: t('统计额度'),
  439. value: renderQuota(consumeQuota),
  440. icon: <IconCoinMoneyStroked size="large" />,
  441. color: 'bg-yellow-50',
  442. avatarColor: 'yellow',
  443. },
  444. {
  445. title: t('统计Tokens'),
  446. value: isNaN(consumeTokens) ? 0 : consumeTokens,
  447. icon: <IconTextStroked size="large" />,
  448. color: 'bg-pink-50',
  449. avatarColor: 'pink',
  450. },
  451. {
  452. title: t('统计次数'),
  453. value: times,
  454. icon: <IconPulse size="large" />,
  455. color: 'bg-teal-50',
  456. avatarColor: 'cyan',
  457. },
  458. {
  459. title: t('平均RPM'),
  460. value: (
  461. times /
  462. ((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000)
  463. ).toFixed(3),
  464. icon: <IconStopwatchStroked size="large" />,
  465. color: 'bg-indigo-50',
  466. avatarColor: 'indigo',
  467. },
  468. {
  469. title: t('平均TPM'),
  470. value: (() => {
  471. const tpm = consumeTokens /
  472. ((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000);
  473. return isNaN(tpm) ? '0' : tpm.toFixed(3);
  474. })(),
  475. icon: <IconTypograph size="large" />,
  476. color: 'bg-orange-50',
  477. avatarColor: 'orange',
  478. },
  479. ];
  480. // 获取问候语
  481. const getGreeting = () => {
  482. const hours = new Date().getHours();
  483. let greeting = '';
  484. if (hours >= 5 && hours < 12) {
  485. greeting = t('早上好');
  486. } else if (hours >= 12 && hours < 14) {
  487. greeting = t('中午好');
  488. } else if (hours >= 14 && hours < 18) {
  489. greeting = t('下午好');
  490. } else {
  491. greeting = t('晚上好');
  492. }
  493. const username = userState?.user?.username || '';
  494. return `👋${greeting},${username}`;
  495. };
  496. return (
  497. <div className="bg-gray-50 min-h-screen">
  498. <div className="flex items-center justify-between mb-4">
  499. <h2 className="text-2xl font-semibold text-gray-800">{getGreeting()}</h2>
  500. <div className="flex gap-3">
  501. <IconButton
  502. icon={<IconSearch />}
  503. onClick={showSearchModal}
  504. className="bg-green-500 text-white hover:bg-green-600 !rounded-full"
  505. />
  506. <IconButton
  507. icon={<IconRefresh />}
  508. onClick={refresh}
  509. loading={loading}
  510. className="bg-blue-500 text-white hover:bg-blue-600 !rounded-full"
  511. />
  512. </div>
  513. </div>
  514. {/* 搜索条件Modal */}
  515. <Modal
  516. title={t('搜索条件')}
  517. visible={searchModalVisible}
  518. onOk={handleSearchConfirm}
  519. onCancel={handleCloseModal}
  520. closeOnEsc={true}
  521. size={isMobile() ? 'full-width' : 'small'}
  522. centered
  523. >
  524. <Form ref={formRef} layout='vertical' className="w-full">
  525. <Form.DatePicker
  526. field='start_timestamp'
  527. label={t('起始时间')}
  528. className="w-full mb-2 !rounded-lg"
  529. initValue={start_timestamp}
  530. value={start_timestamp}
  531. type='dateTime'
  532. name='start_timestamp'
  533. size='large'
  534. onChange={(value) => handleInputChange(value, 'start_timestamp')}
  535. />
  536. <Form.DatePicker
  537. field='end_timestamp'
  538. label={t('结束时间')}
  539. className="w-full mb-2 !rounded-lg"
  540. initValue={end_timestamp}
  541. value={end_timestamp}
  542. type='dateTime'
  543. name='end_timestamp'
  544. size='large'
  545. onChange={(value) => handleInputChange(value, 'end_timestamp')}
  546. />
  547. <Form.Select
  548. field='data_export_default_time'
  549. label={t('时间粒度')}
  550. className="w-full mb-2 !rounded-lg"
  551. initValue={dataExportDefaultTime}
  552. placeholder={t('时间粒度')}
  553. name='data_export_default_time'
  554. size='large'
  555. optionList={[
  556. { label: t('小时'), value: 'hour' },
  557. { label: t('天'), value: 'day' },
  558. { label: t('周'), value: 'week' },
  559. ]}
  560. onChange={(value) => handleInputChange(value, 'data_export_default_time')}
  561. />
  562. {isAdminUser && (
  563. <Form.Input
  564. field='username'
  565. label={t('用户名称')}
  566. className="w-full mb-2 !rounded-lg"
  567. value={username}
  568. placeholder={t('可选值')}
  569. name='username'
  570. size='large'
  571. onChange={(value) => handleInputChange(value, 'username')}
  572. />
  573. )}
  574. </Form>
  575. </Modal>
  576. <Spin spinning={loading}>
  577. <div className="mb-4">
  578. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  579. {statsData.map((stat, idx) => (
  580. <Card
  581. key={idx}
  582. shadows='hover'
  583. className={`${stat.color} border-0 !rounded-2xl w-full`}
  584. headerLine={false}
  585. >
  586. <div className="flex items-center">
  587. <Avatar
  588. className="mr-3"
  589. size="medium"
  590. color={stat.avatarColor}
  591. >
  592. {stat.icon}
  593. </Avatar>
  594. <div>
  595. <div className="text-sm text-gray-500">{stat.title}</div>
  596. <div className="text-xl font-semibold">{stat.value}</div>
  597. </div>
  598. </div>
  599. </Card>
  600. ))}
  601. </div>
  602. </div>
  603. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
  604. <Card shadows='hover' className="shadow-sm !rounded-2xl" headerLine={true} title={t('模型消耗分布')}>
  605. <div style={{ height: 400 }}>
  606. <VChart
  607. spec={spec_line}
  608. option={{ mode: 'desktop-browser' }}
  609. />
  610. </div>
  611. </Card>
  612. <Card shadows='hover' className="shadow-sm !rounded-2xl" headerLine={true} title={t('模型调用次数占比')}>
  613. <div style={{ height: 400 }}>
  614. <VChart
  615. spec={spec_pie}
  616. option={{ mode: 'desktop-browser' }}
  617. />
  618. </div>
  619. </Card>
  620. </div>
  621. </Spin>
  622. </div>
  623. );
  624. };
  625. export default Detail;