LogsTable.js 37 KB

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