LogsTable.js 37 KB

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