LogsTable.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323
  1. import React, { useContext, useEffect, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. API,
  5. copy,
  6. getTodayStartTimestamp,
  7. isAdmin,
  8. showError,
  9. showSuccess,
  10. timestamp2string,
  11. renderAudioModelPrice,
  12. renderClaudeLogContent,
  13. renderClaudeModelPrice,
  14. renderClaudeModelPriceSimple,
  15. renderGroup,
  16. renderLogContent,
  17. renderModelPrice,
  18. renderModelPriceSimple,
  19. renderNumber,
  20. renderQuota,
  21. stringToColor,
  22. getLogOther
  23. } from '../helpers';
  24. import {
  25. Avatar,
  26. Button,
  27. Descriptions,
  28. Modal,
  29. Popover,
  30. Select,
  31. Space,
  32. Spin,
  33. Table,
  34. Tag,
  35. Tooltip,
  36. Checkbox,
  37. Card,
  38. Typography,
  39. Divider,
  40. Input,
  41. DatePicker,
  42. } from '@douyinfe/semi-ui';
  43. import { ITEMS_PER_PAGE } from '../constants';
  44. import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
  45. import {
  46. IconRefresh,
  47. IconSetting,
  48. IconEyeOpened,
  49. IconSearch,
  50. IconCoinMoneyStroked,
  51. IconPulse,
  52. IconTypograph,
  53. } from '@douyinfe/semi-icons';
  54. const { Text } = Typography;
  55. function renderTimestamp(timestamp) {
  56. return <>{timestamp2string(timestamp)}</>;
  57. }
  58. const MODE_OPTIONS = [
  59. { key: 'all', text: 'all', value: 'all' },
  60. { key: 'self', text: 'current user', value: 'self' },
  61. ];
  62. const colors = [
  63. 'amber',
  64. 'blue',
  65. 'cyan',
  66. 'green',
  67. 'grey',
  68. 'indigo',
  69. 'light-blue',
  70. 'lime',
  71. 'orange',
  72. 'pink',
  73. 'purple',
  74. 'red',
  75. 'teal',
  76. 'violet',
  77. 'yellow',
  78. ];
  79. const LogsTable = () => {
  80. const { t } = useTranslation();
  81. function renderType(type) {
  82. switch (type) {
  83. case 1:
  84. return (
  85. <Tag color='cyan' size='large' shape='circle'>
  86. {t('充值')}
  87. </Tag>
  88. );
  89. case 2:
  90. return (
  91. <Tag color='lime' size='large' shape='circle'>
  92. {t('消费')}
  93. </Tag>
  94. );
  95. case 3:
  96. return (
  97. <Tag color='orange' size='large' shape='circle'>
  98. {t('管理')}
  99. </Tag>
  100. );
  101. case 4:
  102. return (
  103. <Tag color='purple' size='large' shape='circle'>
  104. {t('系统')}
  105. </Tag>
  106. );
  107. case 5:
  108. return (
  109. <Tag color='red' size='large' shape='circle'>
  110. {t('错误')}
  111. </Tag>
  112. );
  113. default:
  114. return (
  115. <Tag color='grey' size='large' shape='circle'>
  116. {t('未知')}
  117. </Tag>
  118. );
  119. }
  120. }
  121. function renderIsStream(bool) {
  122. if (bool) {
  123. return (
  124. <Tag color='blue' size='large' shape='circle'>
  125. {t('流')}
  126. </Tag>
  127. );
  128. } else {
  129. return (
  130. <Tag color='purple' size='large' shape='circle'>
  131. {t('非流')}
  132. </Tag>
  133. );
  134. }
  135. }
  136. function renderUseTime(type) {
  137. const time = parseInt(type);
  138. if (time < 101) {
  139. return (
  140. <Tag color='green' size='large' shape='circle'>
  141. {' '}
  142. {time} s{' '}
  143. </Tag>
  144. );
  145. } else if (time < 300) {
  146. return (
  147. <Tag color='orange' size='large' shape='circle'>
  148. {' '}
  149. {time} s{' '}
  150. </Tag>
  151. );
  152. } else {
  153. return (
  154. <Tag color='red' size='large' shape='circle'>
  155. {' '}
  156. {time} s{' '}
  157. </Tag>
  158. );
  159. }
  160. }
  161. function renderFirstUseTime(type) {
  162. let time = parseFloat(type) / 1000.0;
  163. time = time.toFixed(1);
  164. if (time < 3) {
  165. return (
  166. <Tag color='green' size='large' shape='circle'>
  167. {' '}
  168. {time} s{' '}
  169. </Tag>
  170. );
  171. } else if (time < 10) {
  172. return (
  173. <Tag color='orange' size='large' shape='circle'>
  174. {' '}
  175. {time} s{' '}
  176. </Tag>
  177. );
  178. } else {
  179. return (
  180. <Tag color='red' size='large' shape='circle'>
  181. {' '}
  182. {time} s{' '}
  183. </Tag>
  184. );
  185. }
  186. }
  187. function renderModelName(record) {
  188. let other = getLogOther(record.other);
  189. let modelMapped =
  190. other?.is_model_mapped &&
  191. other?.upstream_model_name &&
  192. other?.upstream_model_name !== '';
  193. if (!modelMapped) {
  194. return (
  195. <Tag
  196. color={stringToColor(record.model_name)}
  197. size='large'
  198. shape='circle'
  199. onClick={(event) => {
  200. copyText(event, record.model_name).then((r) => { });
  201. }}
  202. >
  203. {' '}
  204. {record.model_name}{' '}
  205. </Tag>
  206. );
  207. } else {
  208. return (
  209. <>
  210. <Space vertical align={'start'}>
  211. <Popover
  212. content={
  213. <div style={{ padding: 10 }}>
  214. <Space vertical align={'start'}>
  215. <Tag
  216. color={stringToColor(record.model_name)}
  217. size='large'
  218. shape='circle'
  219. onClick={(event) => {
  220. copyText(event, record.model_name).then((r) => { });
  221. }}
  222. >
  223. {t('请求并计费模型')} {record.model_name}{' '}
  224. </Tag>
  225. <Tag
  226. color={stringToColor(other.upstream_model_name)}
  227. size='large'
  228. shape='circle'
  229. onClick={(event) => {
  230. copyText(event, other.upstream_model_name).then(
  231. (r) => { },
  232. );
  233. }}
  234. >
  235. {t('实际模型')} {other.upstream_model_name}{' '}
  236. </Tag>
  237. </Space>
  238. </div>
  239. }
  240. >
  241. <Tag
  242. color={stringToColor(record.model_name)}
  243. size='large'
  244. shape='circle'
  245. onClick={(event) => {
  246. copyText(event, record.model_name).then((r) => { });
  247. }}
  248. suffixIcon={
  249. <IconRefresh
  250. style={{ width: '0.8em', height: '0.8em', opacity: 0.6 }}
  251. />
  252. }
  253. >
  254. {' '}
  255. {record.model_name}{' '}
  256. </Tag>
  257. </Popover>
  258. </Space>
  259. </>
  260. );
  261. }
  262. }
  263. // Define column keys for selection
  264. const COLUMN_KEYS = {
  265. TIME: 'time',
  266. CHANNEL: 'channel',
  267. USERNAME: 'username',
  268. TOKEN: 'token',
  269. GROUP: 'group',
  270. TYPE: 'type',
  271. MODEL: 'model',
  272. USE_TIME: 'use_time',
  273. PROMPT: 'prompt',
  274. COMPLETION: 'completion',
  275. COST: 'cost',
  276. RETRY: 'retry',
  277. DETAILS: 'details',
  278. };
  279. // State for column visibility
  280. const [visibleColumns, setVisibleColumns] = useState({});
  281. const [showColumnSelector, setShowColumnSelector] = useState(false);
  282. // Load saved column preferences from localStorage
  283. useEffect(() => {
  284. const savedColumns = localStorage.getItem('logs-table-columns');
  285. if (savedColumns) {
  286. try {
  287. const parsed = JSON.parse(savedColumns);
  288. // Make sure all columns are accounted for
  289. const defaults = getDefaultColumnVisibility();
  290. const merged = { ...defaults, ...parsed };
  291. setVisibleColumns(merged);
  292. } catch (e) {
  293. console.error('Failed to parse saved column preferences', e);
  294. initDefaultColumns();
  295. }
  296. } else {
  297. initDefaultColumns();
  298. }
  299. }, []);
  300. // Get default column visibility based on user role
  301. const getDefaultColumnVisibility = () => {
  302. return {
  303. [COLUMN_KEYS.TIME]: true,
  304. [COLUMN_KEYS.CHANNEL]: isAdminUser,
  305. [COLUMN_KEYS.USERNAME]: isAdminUser,
  306. [COLUMN_KEYS.TOKEN]: true,
  307. [COLUMN_KEYS.GROUP]: true,
  308. [COLUMN_KEYS.TYPE]: true,
  309. [COLUMN_KEYS.MODEL]: true,
  310. [COLUMN_KEYS.USE_TIME]: true,
  311. [COLUMN_KEYS.PROMPT]: true,
  312. [COLUMN_KEYS.COMPLETION]: true,
  313. [COLUMN_KEYS.COST]: true,
  314. [COLUMN_KEYS.RETRY]: isAdminUser,
  315. [COLUMN_KEYS.DETAILS]: true,
  316. };
  317. };
  318. // Initialize default column visibility
  319. const initDefaultColumns = () => {
  320. const defaults = getDefaultColumnVisibility();
  321. setVisibleColumns(defaults);
  322. localStorage.setItem('logs-table-columns', JSON.stringify(defaults));
  323. };
  324. // Handle column visibility change
  325. const handleColumnVisibilityChange = (columnKey, checked) => {
  326. const updatedColumns = { ...visibleColumns, [columnKey]: checked };
  327. setVisibleColumns(updatedColumns);
  328. };
  329. // Handle "Select All" checkbox
  330. const handleSelectAll = (checked) => {
  331. const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
  332. const updatedColumns = {};
  333. allKeys.forEach((key) => {
  334. // For admin-only columns, only enable them if user is admin
  335. if (
  336. (key === COLUMN_KEYS.CHANNEL ||
  337. key === COLUMN_KEYS.USERNAME ||
  338. key === COLUMN_KEYS.RETRY) &&
  339. !isAdminUser
  340. ) {
  341. updatedColumns[key] = false;
  342. } else {
  343. updatedColumns[key] = checked;
  344. }
  345. });
  346. setVisibleColumns(updatedColumns);
  347. };
  348. // Define all columns
  349. const allColumns = [
  350. {
  351. key: COLUMN_KEYS.TIME,
  352. title: t('时间'),
  353. dataIndex: 'timestamp2string',
  354. width: 180,
  355. },
  356. {
  357. key: COLUMN_KEYS.CHANNEL,
  358. title: t('渠道'),
  359. dataIndex: 'channel',
  360. width: 80,
  361. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  362. render: (text, record, index) => {
  363. return isAdminUser ? (
  364. record.type === 0 || record.type === 2 || record.type === 5 ? (
  365. <div>
  366. {
  367. <Tooltip content={record.channel_name || '[未知]'}>
  368. <Tag
  369. color={colors[parseInt(text) % colors.length]}
  370. size='large'
  371. shape='circle'
  372. >
  373. {' '}
  374. {text}{' '}
  375. </Tag>
  376. </Tooltip>
  377. }
  378. </div>
  379. ) : (
  380. <></>
  381. )
  382. ) : (
  383. <></>
  384. );
  385. },
  386. },
  387. {
  388. key: COLUMN_KEYS.USERNAME,
  389. title: t('用户'),
  390. dataIndex: 'username',
  391. width: 150,
  392. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  393. render: (text, record, index) => {
  394. return isAdminUser ? (
  395. <div>
  396. <Avatar
  397. size='small'
  398. color={stringToColor(text)}
  399. style={{ marginRight: 4 }}
  400. onClick={(event) => {
  401. event.stopPropagation();
  402. showUserInfo(record.user_id);
  403. }}
  404. >
  405. {typeof text === 'string' && text.slice(0, 1)}
  406. </Avatar>
  407. {text}
  408. </div>
  409. ) : (
  410. <></>
  411. );
  412. },
  413. },
  414. {
  415. key: COLUMN_KEYS.TOKEN,
  416. title: t('令牌'),
  417. dataIndex: 'token_name',
  418. width: 160,
  419. render: (text, record, index) => {
  420. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  421. <div>
  422. <Tag
  423. color='grey'
  424. size='large'
  425. shape='circle'
  426. onClick={(event) => {
  427. //cancel the row click event
  428. copyText(event, text);
  429. }}
  430. >
  431. {' '}
  432. {t(text)}{' '}
  433. </Tag>
  434. </div>
  435. ) : (
  436. <></>
  437. );
  438. },
  439. },
  440. {
  441. key: COLUMN_KEYS.GROUP,
  442. title: t('分组'),
  443. dataIndex: 'group',
  444. width: 120,
  445. render: (text, record, index) => {
  446. if (record.type === 0 || record.type === 2 || record.type === 5) {
  447. if (record.group) {
  448. return <>{renderGroup(record.group)}</>;
  449. } else {
  450. let other = null;
  451. try {
  452. other = JSON.parse(record.other);
  453. } catch (e) {
  454. console.error(
  455. `Failed to parse record.other: "${record.other}".`,
  456. e,
  457. );
  458. }
  459. if (other === null) {
  460. return <></>;
  461. }
  462. if (other.group !== undefined) {
  463. return <>{renderGroup(other.group)}</>;
  464. } else {
  465. return <></>;
  466. }
  467. }
  468. } else {
  469. return <></>;
  470. }
  471. },
  472. },
  473. {
  474. key: COLUMN_KEYS.TYPE,
  475. title: t('类型'),
  476. dataIndex: 'type',
  477. width: 100,
  478. render: (text, record, index) => {
  479. return <>{renderType(text)}</>;
  480. },
  481. },
  482. {
  483. key: COLUMN_KEYS.MODEL,
  484. title: t('模型'),
  485. dataIndex: 'model_name',
  486. width: 160,
  487. render: (text, record, index) => {
  488. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  489. <>{renderModelName(record)}</>
  490. ) : (
  491. <></>
  492. );
  493. },
  494. },
  495. {
  496. key: COLUMN_KEYS.USE_TIME,
  497. title: t('用时/首字'),
  498. dataIndex: 'use_time',
  499. width: 160,
  500. render: (text, record, index) => {
  501. if (record.is_stream) {
  502. let other = getLogOther(record.other);
  503. return (
  504. <>
  505. <Space>
  506. {renderUseTime(text)}
  507. {renderFirstUseTime(other?.frt)}
  508. {renderIsStream(record.is_stream)}
  509. </Space>
  510. </>
  511. );
  512. } else {
  513. return (
  514. <>
  515. <Space>
  516. {renderUseTime(text)}
  517. {renderIsStream(record.is_stream)}
  518. </Space>
  519. </>
  520. );
  521. }
  522. },
  523. },
  524. {
  525. key: COLUMN_KEYS.PROMPT,
  526. title: t('提示'),
  527. dataIndex: 'prompt_tokens',
  528. width: 100,
  529. render: (text, record, index) => {
  530. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  531. <>{<span> {text} </span>}</>
  532. ) : (
  533. <></>
  534. );
  535. },
  536. },
  537. {
  538. key: COLUMN_KEYS.COMPLETION,
  539. title: t('补全'),
  540. dataIndex: 'completion_tokens',
  541. width: 100,
  542. render: (text, record, index) => {
  543. return parseInt(text) > 0 &&
  544. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  545. <>{<span> {text} </span>}</>
  546. ) : (
  547. <></>
  548. );
  549. },
  550. },
  551. {
  552. key: COLUMN_KEYS.COST,
  553. title: t('花费'),
  554. dataIndex: 'quota',
  555. width: 120,
  556. render: (text, record, index) => {
  557. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  558. <>{renderQuota(text, 6)}</>
  559. ) : (
  560. <></>
  561. );
  562. },
  563. },
  564. {
  565. key: COLUMN_KEYS.RETRY,
  566. title: t('重试'),
  567. dataIndex: 'retry',
  568. width: 160,
  569. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  570. render: (text, record, index) => {
  571. let content = t('渠道') + `:${record.channel}`;
  572. if (record.other !== '') {
  573. let other = JSON.parse(record.other);
  574. if (other === null) {
  575. return <></>;
  576. }
  577. if (other.admin_info !== undefined) {
  578. if (
  579. other.admin_info.use_channel !== null &&
  580. other.admin_info.use_channel !== undefined &&
  581. other.admin_info.use_channel !== ''
  582. ) {
  583. // channel id array
  584. let useChannel = other.admin_info.use_channel;
  585. let useChannelStr = useChannel.join('->');
  586. content = t('渠道') + `:${useChannelStr}`;
  587. }
  588. }
  589. }
  590. return isAdminUser ? <div>{content}</div> : <></>;
  591. },
  592. },
  593. {
  594. key: COLUMN_KEYS.DETAILS,
  595. title: t('详情'),
  596. dataIndex: 'content',
  597. width: 200,
  598. render: (text, record, index) => {
  599. let other = getLogOther(record.other);
  600. if (other == null || record.type !== 2) {
  601. return (
  602. <Paragraph
  603. ellipsis={{
  604. rows: 2,
  605. showTooltip: {
  606. type: 'popover',
  607. opts: { style: { width: 240 } },
  608. },
  609. }}
  610. style={{ maxWidth: 240 }}
  611. >
  612. {text}
  613. </Paragraph>
  614. );
  615. }
  616. let content = other?.claude
  617. ? renderClaudeModelPriceSimple(
  618. other.model_ratio,
  619. other.model_price,
  620. other.group_ratio,
  621. other.cache_tokens || 0,
  622. other.cache_ratio || 1.0,
  623. other.cache_creation_tokens || 0,
  624. other.cache_creation_ratio || 1.0,
  625. )
  626. : renderModelPriceSimple(
  627. other.model_ratio,
  628. other.model_price,
  629. other.group_ratio,
  630. other.cache_tokens || 0,
  631. other.cache_ratio || 1.0,
  632. );
  633. return (
  634. <Paragraph
  635. ellipsis={{
  636. rows: 2,
  637. }}
  638. style={{ maxWidth: 240 }}
  639. >
  640. {content}
  641. </Paragraph>
  642. );
  643. },
  644. },
  645. ];
  646. // Update table when column visibility changes
  647. useEffect(() => {
  648. if (Object.keys(visibleColumns).length > 0) {
  649. // Save to localStorage
  650. localStorage.setItem(
  651. 'logs-table-columns',
  652. JSON.stringify(visibleColumns),
  653. );
  654. }
  655. }, [visibleColumns]);
  656. // Filter columns based on visibility settings
  657. const getVisibleColumns = () => {
  658. return allColumns.filter((column) => visibleColumns[column.key]);
  659. };
  660. // Column selector modal
  661. const renderColumnSelector = () => {
  662. return (
  663. <Modal
  664. title={t('列设置')}
  665. visible={showColumnSelector}
  666. onCancel={() => setShowColumnSelector(false)}
  667. footer={
  668. <div className="flex justify-end">
  669. <Button
  670. theme="light"
  671. onClick={() => initDefaultColumns()}
  672. className="!rounded-full"
  673. >
  674. {t('重置')}
  675. </Button>
  676. <Button
  677. theme="light"
  678. onClick={() => setShowColumnSelector(false)}
  679. className="!rounded-full"
  680. >
  681. {t('取消')}
  682. </Button>
  683. <Button
  684. type='primary'
  685. onClick={() => setShowColumnSelector(false)}
  686. className="!rounded-full"
  687. >
  688. {t('确定')}
  689. </Button>
  690. </div>
  691. }
  692. >
  693. <div style={{ marginBottom: 20 }}>
  694. <Checkbox
  695. checked={Object.values(visibleColumns).every((v) => v === true)}
  696. indeterminate={
  697. Object.values(visibleColumns).some((v) => v === true) &&
  698. !Object.values(visibleColumns).every((v) => v === true)
  699. }
  700. onChange={(e) => handleSelectAll(e.target.checked)}
  701. >
  702. {t('全选')}
  703. </Checkbox>
  704. </div>
  705. <div
  706. className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
  707. style={{ border: '1px solid var(--semi-color-border)' }}
  708. >
  709. {allColumns.map((column) => {
  710. // Skip admin-only columns for non-admin users
  711. if (
  712. !isAdminUser &&
  713. (column.key === COLUMN_KEYS.CHANNEL ||
  714. column.key === COLUMN_KEYS.USERNAME ||
  715. column.key === COLUMN_KEYS.RETRY)
  716. ) {
  717. return null;
  718. }
  719. return (
  720. <div
  721. key={column.key}
  722. className="w-1/2 mb-4 pr-2"
  723. >
  724. <Checkbox
  725. checked={!!visibleColumns[column.key]}
  726. onChange={(e) =>
  727. handleColumnVisibilityChange(column.key, e.target.checked)
  728. }
  729. >
  730. {column.title}
  731. </Checkbox>
  732. </div>
  733. );
  734. })}
  735. </div>
  736. </Modal>
  737. );
  738. };
  739. const [logs, setLogs] = useState([]);
  740. const [expandData, setExpandData] = useState({});
  741. const [showStat, setShowStat] = useState(false);
  742. const [loading, setLoading] = useState(false);
  743. const [loadingStat, setLoadingStat] = useState(false);
  744. const [activePage, setActivePage] = useState(1);
  745. const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
  746. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  747. const [logType, setLogType] = useState(0);
  748. const isAdminUser = isAdmin();
  749. let now = new Date();
  750. // 初始化start_timestamp为今天0点
  751. const [inputs, setInputs] = useState({
  752. username: '',
  753. token_name: '',
  754. model_name: '',
  755. start_timestamp: timestamp2string(getTodayStartTimestamp()),
  756. end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
  757. channel: '',
  758. group: '',
  759. });
  760. const {
  761. username,
  762. token_name,
  763. model_name,
  764. start_timestamp,
  765. end_timestamp,
  766. channel,
  767. group,
  768. } = inputs;
  769. const [stat, setStat] = useState({
  770. quota: 0,
  771. token: 0,
  772. });
  773. const handleInputChange = (value, name) => {
  774. setInputs((inputs) => ({ ...inputs, [name]: value }));
  775. };
  776. const getLogSelfStat = async () => {
  777. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  778. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  779. let url = `/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
  780. url = encodeURI(url);
  781. let res = await API.get(url);
  782. const { success, message, data } = res.data;
  783. if (success) {
  784. setStat(data);
  785. } else {
  786. showError(message);
  787. }
  788. };
  789. const getLogStat = async () => {
  790. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  791. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  792. let url = `/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
  793. url = encodeURI(url);
  794. let res = await API.get(url);
  795. const { success, message, data } = res.data;
  796. if (success) {
  797. setStat(data);
  798. } else {
  799. showError(message);
  800. }
  801. };
  802. const handleEyeClick = async () => {
  803. if (loadingStat) {
  804. return;
  805. }
  806. setLoadingStat(true);
  807. if (isAdminUser) {
  808. await getLogStat();
  809. } else {
  810. await getLogSelfStat();
  811. }
  812. setShowStat(true);
  813. setLoadingStat(false);
  814. };
  815. const showUserInfo = async (userId) => {
  816. if (!isAdminUser) {
  817. return;
  818. }
  819. const res = await API.get(`/api/user/${userId}`);
  820. const { success, message, data } = res.data;
  821. if (success) {
  822. Modal.info({
  823. title: t('用户信息'),
  824. content: (
  825. <div style={{ padding: 12 }}>
  826. <p>
  827. {t('用户名')}: {data.username}
  828. </p>
  829. <p>
  830. {t('余额')}: {renderQuota(data.quota)}
  831. </p>
  832. <p>
  833. {t('已用额度')}:{renderQuota(data.used_quota)}
  834. </p>
  835. <p>
  836. {t('请求次数')}:{renderNumber(data.request_count)}
  837. </p>
  838. </div>
  839. ),
  840. centered: true,
  841. });
  842. } else {
  843. showError(message);
  844. }
  845. };
  846. const setLogsFormat = (logs) => {
  847. let expandDatesLocal = {};
  848. for (let i = 0; i < logs.length; i++) {
  849. logs[i].timestamp2string = timestamp2string(logs[i].created_at);
  850. logs[i].key = logs[i].id;
  851. let other = getLogOther(logs[i].other);
  852. let expandDataLocal = [];
  853. if (isAdmin()) {
  854. // let content = '渠道:' + logs[i].channel;
  855. // if (other.admin_info !== undefined) {
  856. // if (
  857. // other.admin_info.use_channel !== null &&
  858. // other.admin_info.use_channel !== undefined &&
  859. // other.admin_info.use_channel !== ''
  860. // ) {
  861. // // channel id array
  862. // let useChannel = other.admin_info.use_channel;
  863. // let useChannelStr = useChannel.join('->');
  864. // content = `渠道:${useChannelStr}`;
  865. // }
  866. // }
  867. // expandDataLocal.push({
  868. // key: '渠道重试',
  869. // value: content,
  870. // })
  871. }
  872. if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
  873. expandDataLocal.push({
  874. key: t('渠道信息'),
  875. value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
  876. });
  877. }
  878. if (other?.ws || other?.audio) {
  879. expandDataLocal.push({
  880. key: t('语音输入'),
  881. value: other.audio_input,
  882. });
  883. expandDataLocal.push({
  884. key: t('语音输出'),
  885. value: other.audio_output,
  886. });
  887. expandDataLocal.push({
  888. key: t('文字输入'),
  889. value: other.text_input,
  890. });
  891. expandDataLocal.push({
  892. key: t('文字输出'),
  893. value: other.text_output,
  894. });
  895. }
  896. if (other?.cache_tokens > 0) {
  897. expandDataLocal.push({
  898. key: t('缓存 Tokens'),
  899. value: other.cache_tokens,
  900. });
  901. }
  902. if (other?.cache_creation_tokens > 0) {
  903. expandDataLocal.push({
  904. key: t('缓存创建 Tokens'),
  905. value: other.cache_creation_tokens,
  906. });
  907. }
  908. if (logs[i].type === 2) {
  909. expandDataLocal.push({
  910. key: t('日志详情'),
  911. value: other?.claude
  912. ? renderClaudeLogContent(
  913. other?.model_ratio,
  914. other.completion_ratio,
  915. other.model_price,
  916. other.group_ratio,
  917. other.cache_ratio || 1.0,
  918. other.cache_creation_ratio || 1.0,
  919. )
  920. : renderLogContent(
  921. other?.model_ratio,
  922. other.completion_ratio,
  923. other.model_price,
  924. other.group_ratio,
  925. other?.user_group_ratio,
  926. false,
  927. 1.0,
  928. undefined,
  929. other.web_search || false,
  930. other.web_search_call_count || 0,
  931. other.file_search || false,
  932. other.file_search_call_count || 0,
  933. ),
  934. });
  935. }
  936. if (logs[i].type === 2) {
  937. let modelMapped =
  938. other?.is_model_mapped &&
  939. other?.upstream_model_name &&
  940. other?.upstream_model_name !== '';
  941. if (modelMapped) {
  942. expandDataLocal.push({
  943. key: t('请求并计费模型'),
  944. value: logs[i].model_name,
  945. });
  946. expandDataLocal.push({
  947. key: t('实际模型'),
  948. value: other.upstream_model_name,
  949. });
  950. }
  951. let content = '';
  952. if (other?.ws || other?.audio) {
  953. content = renderAudioModelPrice(
  954. other?.text_input,
  955. other?.text_output,
  956. other?.model_ratio,
  957. other?.model_price,
  958. other?.completion_ratio,
  959. other?.audio_input,
  960. other?.audio_output,
  961. other?.audio_ratio,
  962. other?.audio_completion_ratio,
  963. other?.group_ratio,
  964. other?.cache_tokens || 0,
  965. other?.cache_ratio || 1.0,
  966. );
  967. } else if (other?.claude) {
  968. content = renderClaudeModelPrice(
  969. logs[i].prompt_tokens,
  970. logs[i].completion_tokens,
  971. other.model_ratio,
  972. other.model_price,
  973. other.completion_ratio,
  974. other.group_ratio,
  975. other.cache_tokens || 0,
  976. other.cache_ratio || 1.0,
  977. other.cache_creation_tokens || 0,
  978. other.cache_creation_ratio || 1.0,
  979. );
  980. } else {
  981. content = renderModelPrice(
  982. logs[i].prompt_tokens,
  983. logs[i].completion_tokens,
  984. other?.model_ratio,
  985. other?.model_price,
  986. other?.completion_ratio,
  987. other?.group_ratio,
  988. other?.cache_tokens || 0,
  989. other?.cache_ratio || 1.0,
  990. other?.image || false,
  991. other?.image_ratio || 0,
  992. other?.image_output || 0,
  993. other?.web_search || false,
  994. other?.web_search_call_count || 0,
  995. other?.web_search_price || 0,
  996. other?.file_search || false,
  997. other?.file_search_call_count || 0,
  998. other?.file_search_price || 0,
  999. );
  1000. }
  1001. expandDataLocal.push({
  1002. key: t('计费过程'),
  1003. value: content,
  1004. });
  1005. if (other?.reasoning_effort) {
  1006. expandDataLocal.push({
  1007. key: t('Reasoning Effort'),
  1008. value: other.reasoning_effort,
  1009. });
  1010. }
  1011. }
  1012. expandDatesLocal[logs[i].key] = expandDataLocal;
  1013. }
  1014. setExpandData(expandDatesLocal);
  1015. setLogs(logs);
  1016. };
  1017. const loadLogs = async (startIdx, pageSize, logType = 0) => {
  1018. setLoading(true);
  1019. let url = '';
  1020. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  1021. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  1022. if (isAdminUser) {
  1023. url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
  1024. } else {
  1025. url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
  1026. }
  1027. url = encodeURI(url);
  1028. const res = await API.get(url);
  1029. const { success, message, data } = res.data;
  1030. if (success) {
  1031. const newPageData = data.items;
  1032. setActivePage(data.page);
  1033. setPageSize(data.page_size);
  1034. setLogCount(data.total);
  1035. setLogsFormat(newPageData);
  1036. } else {
  1037. showError(message);
  1038. }
  1039. setLoading(false);
  1040. };
  1041. const handlePageChange = (page) => {
  1042. setActivePage(page);
  1043. loadLogs(page, pageSize, logType).then((r) => { });
  1044. };
  1045. const handlePageSizeChange = async (size) => {
  1046. localStorage.setItem('page-size', size + '');
  1047. setPageSize(size);
  1048. setActivePage(1);
  1049. loadLogs(activePage, size)
  1050. .then()
  1051. .catch((reason) => {
  1052. showError(reason);
  1053. });
  1054. };
  1055. const refresh = async () => {
  1056. setActivePage(1);
  1057. handleEyeClick();
  1058. await loadLogs(activePage, pageSize, logType);
  1059. };
  1060. const copyText = async (e, text) => {
  1061. e.stopPropagation();
  1062. if (await copy(text)) {
  1063. showSuccess('已复制:' + text);
  1064. } else {
  1065. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  1066. }
  1067. };
  1068. useEffect(() => {
  1069. const localPageSize =
  1070. parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
  1071. setPageSize(localPageSize);
  1072. loadLogs(activePage, localPageSize)
  1073. .then()
  1074. .catch((reason) => {
  1075. showError(reason);
  1076. });
  1077. handleEyeClick();
  1078. }, []);
  1079. const expandRowRender = (record, index) => {
  1080. return <Descriptions data={expandData[record.key]} />;
  1081. };
  1082. return (
  1083. <>
  1084. {renderColumnSelector()}
  1085. <Card
  1086. className="!rounded-2xl overflow-hidden mb-4"
  1087. title={
  1088. <div className="flex flex-col w-full">
  1089. <Spin spinning={loadingStat}>
  1090. <Space>
  1091. <Tag
  1092. color='blue'
  1093. size='large'
  1094. style={{
  1095. padding: 15,
  1096. borderRadius: '9999px',
  1097. fontWeight: 500,
  1098. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
  1099. }}
  1100. >
  1101. {t('消耗额度')}: {renderQuota(stat.quota)}
  1102. </Tag>
  1103. <Tag
  1104. color='pink'
  1105. size='large'
  1106. style={{
  1107. padding: 15,
  1108. borderRadius: '9999px',
  1109. fontWeight: 500,
  1110. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
  1111. }}
  1112. >
  1113. RPM: {stat.rpm}
  1114. </Tag>
  1115. <Tag
  1116. color='white'
  1117. size='large'
  1118. style={{
  1119. padding: 15,
  1120. border: 'none',
  1121. boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
  1122. borderRadius: '9999px',
  1123. fontWeight: 500,
  1124. }}
  1125. >
  1126. TPM: {stat.tpm}
  1127. </Tag>
  1128. </Space>
  1129. </Spin>
  1130. <Divider margin="12px" />
  1131. {/* 搜索表单区域 */}
  1132. <div className="flex flex-col gap-4">
  1133. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  1134. {/* 时间选择器 */}
  1135. <div className="col-span-1 lg:col-span-2">
  1136. <DatePicker
  1137. className="w-full"
  1138. value={[start_timestamp, end_timestamp]}
  1139. type='dateTimeRange'
  1140. onChange={(value) => {
  1141. if (Array.isArray(value) && value.length === 2) {
  1142. handleInputChange(value[0], 'start_timestamp');
  1143. handleInputChange(value[1], 'end_timestamp');
  1144. }
  1145. }}
  1146. />
  1147. </div>
  1148. {/* 日志类型选择器 */}
  1149. <Select
  1150. value={logType.toString()}
  1151. placeholder={t('日志类型')}
  1152. className="!rounded-full"
  1153. onChange={(value) => {
  1154. setLogType(parseInt(value));
  1155. loadLogs(0, pageSize, parseInt(value));
  1156. }}
  1157. >
  1158. <Select.Option value='0'>{t('全部')}</Select.Option>
  1159. <Select.Option value='1'>{t('充值')}</Select.Option>
  1160. <Select.Option value='2'>{t('消费')}</Select.Option>
  1161. <Select.Option value='3'>{t('管理')}</Select.Option>
  1162. <Select.Option value='4'>{t('系统')}</Select.Option>
  1163. <Select.Option value='5'>{t('错误')}</Select.Option>
  1164. </Select>
  1165. {/* 其他搜索字段 */}
  1166. <Input
  1167. prefix={<IconSearch />}
  1168. placeholder={t('令牌名称')}
  1169. value={token_name}
  1170. onChange={(value) => handleInputChange(value, 'token_name')}
  1171. className="!rounded-full"
  1172. showClear
  1173. />
  1174. <Input
  1175. prefix={<IconSearch />}
  1176. placeholder={t('模型名称')}
  1177. value={model_name}
  1178. onChange={(value) => handleInputChange(value, 'model_name')}
  1179. className="!rounded-full"
  1180. showClear
  1181. />
  1182. <Input
  1183. prefix={<IconSearch />}
  1184. placeholder={t('分组')}
  1185. value={group}
  1186. onChange={(value) => handleInputChange(value, 'group')}
  1187. className="!rounded-full"
  1188. showClear
  1189. />
  1190. {isAdminUser && (
  1191. <>
  1192. <Input
  1193. prefix={<IconSearch />}
  1194. placeholder={t('渠道 ID')}
  1195. value={channel}
  1196. onChange={(value) => handleInputChange(value, 'channel')}
  1197. className="!rounded-full"
  1198. showClear
  1199. />
  1200. <Input
  1201. prefix={<IconSearch />}
  1202. placeholder={t('用户名称')}
  1203. value={username}
  1204. onChange={(value) => handleInputChange(value, 'username')}
  1205. className="!rounded-full"
  1206. showClear
  1207. />
  1208. </>
  1209. )}
  1210. </div>
  1211. {/* 操作按钮区域 */}
  1212. <div className="flex justify-between items-center pt-2">
  1213. <div></div>
  1214. <div className="flex gap-2">
  1215. <Button
  1216. type='primary'
  1217. onClick={refresh}
  1218. loading={loading}
  1219. className="!rounded-full"
  1220. >
  1221. {t('查询')}
  1222. </Button>
  1223. <Button
  1224. theme='light'
  1225. type='tertiary'
  1226. icon={<IconSetting />}
  1227. onClick={() => setShowColumnSelector(true)}
  1228. className="!rounded-full"
  1229. >
  1230. {t('列设置')}
  1231. </Button>
  1232. </div>
  1233. </div>
  1234. </div>
  1235. </div>
  1236. }
  1237. shadows='hover'
  1238. >
  1239. <Table
  1240. columns={getVisibleColumns()}
  1241. expandedRowRender={expandRowRender}
  1242. expandRowByClick={true}
  1243. dataSource={logs}
  1244. rowKey='key'
  1245. loading={loading}
  1246. className="rounded-xl overflow-hidden"
  1247. size="middle"
  1248. pagination={{
  1249. formatPageText: (page) =>
  1250. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  1251. start: page.currentStart,
  1252. end: page.currentEnd,
  1253. total: logCount,
  1254. }),
  1255. currentPage: activePage,
  1256. pageSize: pageSize,
  1257. total: logCount,
  1258. pageSizeOptions: [10, 20, 50, 100],
  1259. showSizeChanger: true,
  1260. onPageSizeChange: (size) => {
  1261. handlePageSizeChange(size);
  1262. },
  1263. onPageChange: handlePageChange,
  1264. }}
  1265. />
  1266. </Card>
  1267. </>
  1268. );
  1269. };
  1270. export default LogsTable;