index.js 17 KB

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