index.js 17 KB

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