LogsTable.js 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303
  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. } from '../helpers';
  12. import {
  13. Avatar,
  14. Button,
  15. Descriptions,
  16. Modal,
  17. Popover,
  18. Select,
  19. Space,
  20. Spin,
  21. Table,
  22. Tag,
  23. Tooltip,
  24. Checkbox,
  25. Card,
  26. Typography,
  27. Divider,
  28. Input,
  29. DatePicker,
  30. } from '@douyinfe/semi-ui';
  31. import { ITEMS_PER_PAGE } from '../constants';
  32. import {
  33. renderAudioModelPrice,
  34. renderClaudeLogContent,
  35. renderClaudeModelPrice,
  36. renderClaudeModelPriceSimple,
  37. renderGroup,
  38. renderLogContent,
  39. renderModelPrice,
  40. renderModelPriceSimple,
  41. renderNumber,
  42. renderQuota,
  43. stringToColor,
  44. } from '../helpers/render';
  45. import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
  46. import { getLogOther } from '../helpers/other.js';
  47. import { StyleContext } from '../context/Style/index.js';
  48. import {
  49. IconRefresh,
  50. IconSetting,
  51. IconEyeOpened,
  52. IconSearch,
  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. },
  355. {
  356. key: COLUMN_KEYS.CHANNEL,
  357. title: t('渠道'),
  358. dataIndex: 'channel',
  359. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  360. render: (text, record, index) => {
  361. return isAdminUser ? (
  362. record.type === 0 || record.type === 2 || record.type === 5 ? (
  363. <div>
  364. {
  365. <Tooltip content={record.channel_name || '[未知]'}>
  366. <Tag
  367. color={colors[parseInt(text) % colors.length]}
  368. size='large'
  369. shape='circle'
  370. >
  371. {' '}
  372. {text}{' '}
  373. </Tag>
  374. </Tooltip>
  375. }
  376. </div>
  377. ) : (
  378. <></>
  379. )
  380. ) : (
  381. <></>
  382. );
  383. },
  384. },
  385. {
  386. key: COLUMN_KEYS.USERNAME,
  387. title: t('用户'),
  388. dataIndex: 'username',
  389. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  390. render: (text, record, index) => {
  391. return isAdminUser ? (
  392. <div>
  393. <Avatar
  394. size='small'
  395. color={stringToColor(text)}
  396. style={{ marginRight: 4 }}
  397. onClick={(event) => {
  398. event.stopPropagation();
  399. showUserInfo(record.user_id);
  400. }}
  401. >
  402. {typeof text === 'string' && text.slice(0, 1)}
  403. </Avatar>
  404. {text}
  405. </div>
  406. ) : (
  407. <></>
  408. );
  409. },
  410. },
  411. {
  412. key: COLUMN_KEYS.TOKEN,
  413. title: t('令牌'),
  414. dataIndex: 'token_name',
  415. render: (text, record, index) => {
  416. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  417. <div>
  418. <Tag
  419. color='grey'
  420. size='large'
  421. shape='circle'
  422. onClick={(event) => {
  423. //cancel the row click event
  424. copyText(event, text);
  425. }}
  426. >
  427. {' '}
  428. {t(text)}{' '}
  429. </Tag>
  430. </div>
  431. ) : (
  432. <></>
  433. );
  434. },
  435. },
  436. {
  437. key: COLUMN_KEYS.GROUP,
  438. title: t('分组'),
  439. dataIndex: 'group',
  440. render: (text, record, index) => {
  441. if (record.type === 0 || record.type === 2 || record.type === 5) {
  442. if (record.group) {
  443. return <>{renderGroup(record.group)}</>;
  444. } else {
  445. let other = null;
  446. try {
  447. other = JSON.parse(record.other);
  448. } catch (e) {
  449. console.error(
  450. `Failed to parse record.other: "${record.other}".`,
  451. e,
  452. );
  453. }
  454. if (other === null) {
  455. return <></>;
  456. }
  457. if (other.group !== undefined) {
  458. return <>{renderGroup(other.group)}</>;
  459. } else {
  460. return <></>;
  461. }
  462. }
  463. } else {
  464. return <></>;
  465. }
  466. },
  467. },
  468. {
  469. key: COLUMN_KEYS.TYPE,
  470. title: t('类型'),
  471. dataIndex: 'type',
  472. render: (text, record, index) => {
  473. return <>{renderType(text)}</>;
  474. },
  475. },
  476. {
  477. key: COLUMN_KEYS.MODEL,
  478. title: t('模型'),
  479. dataIndex: 'model_name',
  480. render: (text, record, index) => {
  481. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  482. <>{renderModelName(record)}</>
  483. ) : (
  484. <></>
  485. );
  486. },
  487. },
  488. {
  489. key: COLUMN_KEYS.USE_TIME,
  490. title: t('用时/首字'),
  491. dataIndex: 'use_time',
  492. render: (text, record, index) => {
  493. if (record.is_stream) {
  494. let other = getLogOther(record.other);
  495. return (
  496. <>
  497. <Space>
  498. {renderUseTime(text)}
  499. {renderFirstUseTime(other?.frt)}
  500. {renderIsStream(record.is_stream)}
  501. </Space>
  502. </>
  503. );
  504. } else {
  505. return (
  506. <>
  507. <Space>
  508. {renderUseTime(text)}
  509. {renderIsStream(record.is_stream)}
  510. </Space>
  511. </>
  512. );
  513. }
  514. },
  515. },
  516. {
  517. key: COLUMN_KEYS.PROMPT,
  518. title: t('提示'),
  519. dataIndex: 'prompt_tokens',
  520. render: (text, record, index) => {
  521. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  522. <>{<span> {text} </span>}</>
  523. ) : (
  524. <></>
  525. );
  526. },
  527. },
  528. {
  529. key: COLUMN_KEYS.COMPLETION,
  530. title: t('补全'),
  531. dataIndex: 'completion_tokens',
  532. render: (text, record, index) => {
  533. return parseInt(text) > 0 &&
  534. (record.type === 0 || record.type === 2 || record.type === 5) ? (
  535. <>{<span> {text} </span>}</>
  536. ) : (
  537. <></>
  538. );
  539. },
  540. },
  541. {
  542. key: COLUMN_KEYS.COST,
  543. title: t('花费'),
  544. dataIndex: 'quota',
  545. render: (text, record, index) => {
  546. return record.type === 0 || record.type === 2 || record.type === 5 ? (
  547. <>{renderQuota(text, 6)}</>
  548. ) : (
  549. <></>
  550. );
  551. },
  552. },
  553. {
  554. key: COLUMN_KEYS.RETRY,
  555. title: t('重试'),
  556. dataIndex: 'retry',
  557. className: isAdmin() ? 'tableShow' : 'tableHiddle',
  558. render: (text, record, index) => {
  559. let content = t('渠道') + `:${record.channel}`;
  560. if (record.other !== '') {
  561. let other = JSON.parse(record.other);
  562. if (other === null) {
  563. return <></>;
  564. }
  565. if (other.admin_info !== undefined) {
  566. if (
  567. other.admin_info.use_channel !== null &&
  568. other.admin_info.use_channel !== undefined &&
  569. other.admin_info.use_channel !== ''
  570. ) {
  571. // channel id array
  572. let useChannel = other.admin_info.use_channel;
  573. let useChannelStr = useChannel.join('->');
  574. content = t('渠道') + `:${useChannelStr}`;
  575. }
  576. }
  577. }
  578. return isAdminUser ? <div>{content}</div> : <></>;
  579. },
  580. },
  581. {
  582. key: COLUMN_KEYS.DETAILS,
  583. title: t('详情'),
  584. dataIndex: 'content',
  585. render: (text, record, index) => {
  586. let other = getLogOther(record.other);
  587. if (other == null || record.type !== 2) {
  588. return (
  589. <Paragraph
  590. ellipsis={{
  591. rows: 2,
  592. showTooltip: {
  593. type: 'popover',
  594. opts: { style: { width: 240 } },
  595. },
  596. }}
  597. style={{ maxWidth: 240 }}
  598. >
  599. {text}
  600. </Paragraph>
  601. );
  602. }
  603. let content = other?.claude
  604. ? renderClaudeModelPriceSimple(
  605. other.model_ratio,
  606. other.model_price,
  607. other.group_ratio,
  608. other.cache_tokens || 0,
  609. other.cache_ratio || 1.0,
  610. other.cache_creation_tokens || 0,
  611. other.cache_creation_ratio || 1.0,
  612. )
  613. : renderModelPriceSimple(
  614. other.model_ratio,
  615. other.model_price,
  616. other.group_ratio,
  617. other.cache_tokens || 0,
  618. other.cache_ratio || 1.0,
  619. );
  620. return (
  621. <Paragraph
  622. ellipsis={{
  623. rows: 2,
  624. }}
  625. style={{ maxWidth: 240 }}
  626. >
  627. {content}
  628. </Paragraph>
  629. );
  630. },
  631. },
  632. ];
  633. // Update table when column visibility changes
  634. useEffect(() => {
  635. if (Object.keys(visibleColumns).length > 0) {
  636. // Save to localStorage
  637. localStorage.setItem(
  638. 'logs-table-columns',
  639. JSON.stringify(visibleColumns),
  640. );
  641. }
  642. }, [visibleColumns]);
  643. // Filter columns based on visibility settings
  644. const getVisibleColumns = () => {
  645. return allColumns.filter((column) => visibleColumns[column.key]);
  646. };
  647. // Column selector modal
  648. const renderColumnSelector = () => {
  649. return (
  650. <Modal
  651. title={t('列设置')}
  652. visible={showColumnSelector}
  653. onCancel={() => setShowColumnSelector(false)}
  654. footer={
  655. <div className="flex justify-end">
  656. <Button
  657. theme="light"
  658. onClick={() => initDefaultColumns()}
  659. className="!rounded-full"
  660. >
  661. {t('重置')}
  662. </Button>
  663. <Button
  664. theme="light"
  665. onClick={() => setShowColumnSelector(false)}
  666. className="!rounded-full"
  667. >
  668. {t('取消')}
  669. </Button>
  670. <Button
  671. type='primary'
  672. onClick={() => setShowColumnSelector(false)}
  673. className="!rounded-full"
  674. >
  675. {t('确定')}
  676. </Button>
  677. </div>
  678. }
  679. >
  680. <div style={{ marginBottom: 20 }}>
  681. <Checkbox
  682. checked={Object.values(visibleColumns).every((v) => v === true)}
  683. indeterminate={
  684. Object.values(visibleColumns).some((v) => v === true) &&
  685. !Object.values(visibleColumns).every((v) => v === true)
  686. }
  687. onChange={(e) => handleSelectAll(e.target.checked)}
  688. >
  689. {t('全选')}
  690. </Checkbox>
  691. </div>
  692. <div
  693. className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
  694. >
  695. {allColumns.map((column) => {
  696. // Skip admin-only columns for non-admin users
  697. if (
  698. !isAdminUser &&
  699. (column.key === COLUMN_KEYS.CHANNEL ||
  700. column.key === COLUMN_KEYS.USERNAME ||
  701. column.key === COLUMN_KEYS.RETRY)
  702. ) {
  703. return null;
  704. }
  705. return (
  706. <div
  707. key={column.key}
  708. className="w-1/2 mb-4 pr-2"
  709. >
  710. <Checkbox
  711. checked={!!visibleColumns[column.key]}
  712. onChange={(e) =>
  713. handleColumnVisibilityChange(column.key, e.target.checked)
  714. }
  715. >
  716. {column.title}
  717. </Checkbox>
  718. </div>
  719. );
  720. })}
  721. </div>
  722. </Modal>
  723. );
  724. };
  725. const [styleState, styleDispatch] = useContext(StyleContext);
  726. const [logs, setLogs] = useState([]);
  727. const [expandData, setExpandData] = useState({});
  728. const [showStat, setShowStat] = useState(false);
  729. const [loading, setLoading] = useState(false);
  730. const [loadingStat, setLoadingStat] = useState(false);
  731. const [activePage, setActivePage] = useState(1);
  732. const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
  733. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  734. const [logType, setLogType] = useState(0);
  735. const isAdminUser = isAdmin();
  736. let now = new Date();
  737. // 初始化start_timestamp为今天0点
  738. const [inputs, setInputs] = useState({
  739. username: '',
  740. token_name: '',
  741. model_name: '',
  742. start_timestamp: timestamp2string(getTodayStartTimestamp()),
  743. end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
  744. channel: '',
  745. group: '',
  746. });
  747. const {
  748. username,
  749. token_name,
  750. model_name,
  751. start_timestamp,
  752. end_timestamp,
  753. channel,
  754. group,
  755. } = inputs;
  756. const [stat, setStat] = useState({
  757. quota: 0,
  758. token: 0,
  759. });
  760. const handleInputChange = (value, name) => {
  761. setInputs((inputs) => ({ ...inputs, [name]: value }));
  762. };
  763. const getLogSelfStat = async () => {
  764. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  765. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  766. let url = `/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
  767. url = encodeURI(url);
  768. let res = await API.get(url);
  769. const { success, message, data } = res.data;
  770. if (success) {
  771. setStat(data);
  772. } else {
  773. showError(message);
  774. }
  775. };
  776. const getLogStat = async () => {
  777. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  778. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  779. 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}`;
  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 handleEyeClick = async () => {
  790. if (loadingStat) {
  791. return;
  792. }
  793. setLoadingStat(true);
  794. if (isAdminUser) {
  795. await getLogStat();
  796. } else {
  797. await getLogSelfStat();
  798. }
  799. setShowStat(true);
  800. setLoadingStat(false);
  801. };
  802. const showUserInfo = async (userId) => {
  803. if (!isAdminUser) {
  804. return;
  805. }
  806. const res = await API.get(`/api/user/${userId}`);
  807. const { success, message, data } = res.data;
  808. if (success) {
  809. Modal.info({
  810. title: t('用户信息'),
  811. content: (
  812. <div style={{ padding: 12 }}>
  813. <p>
  814. {t('用户名')}: {data.username}
  815. </p>
  816. <p>
  817. {t('余额')}: {renderQuota(data.quota)}
  818. </p>
  819. <p>
  820. {t('已用额度')}:{renderQuota(data.used_quota)}
  821. </p>
  822. <p>
  823. {t('请求次数')}:{renderNumber(data.request_count)}
  824. </p>
  825. </div>
  826. ),
  827. centered: true,
  828. });
  829. } else {
  830. showError(message);
  831. }
  832. };
  833. const setLogsFormat = (logs) => {
  834. let expandDatesLocal = {};
  835. for (let i = 0; i < logs.length; i++) {
  836. logs[i].timestamp2string = timestamp2string(logs[i].created_at);
  837. logs[i].key = logs[i].id;
  838. let other = getLogOther(logs[i].other);
  839. let expandDataLocal = [];
  840. if (isAdmin()) {
  841. // let content = '渠道:' + logs[i].channel;
  842. // if (other.admin_info !== undefined) {
  843. // if (
  844. // other.admin_info.use_channel !== null &&
  845. // other.admin_info.use_channel !== undefined &&
  846. // other.admin_info.use_channel !== ''
  847. // ) {
  848. // // channel id array
  849. // let useChannel = other.admin_info.use_channel;
  850. // let useChannelStr = useChannel.join('->');
  851. // content = `渠道:${useChannelStr}`;
  852. // }
  853. // }
  854. // expandDataLocal.push({
  855. // key: '渠道重试',
  856. // value: content,
  857. // })
  858. }
  859. if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
  860. expandDataLocal.push({
  861. key: t('渠道信息'),
  862. value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
  863. });
  864. }
  865. if (other?.ws || other?.audio) {
  866. expandDataLocal.push({
  867. key: t('语音输入'),
  868. value: other.audio_input,
  869. });
  870. expandDataLocal.push({
  871. key: t('语音输出'),
  872. value: other.audio_output,
  873. });
  874. expandDataLocal.push({
  875. key: t('文字输入'),
  876. value: other.text_input,
  877. });
  878. expandDataLocal.push({
  879. key: t('文字输出'),
  880. value: other.text_output,
  881. });
  882. }
  883. if (other?.cache_tokens > 0) {
  884. expandDataLocal.push({
  885. key: t('缓存 Tokens'),
  886. value: other.cache_tokens,
  887. });
  888. }
  889. if (other?.cache_creation_tokens > 0) {
  890. expandDataLocal.push({
  891. key: t('缓存创建 Tokens'),
  892. value: other.cache_creation_tokens,
  893. });
  894. }
  895. if (logs[i].type === 2) {
  896. expandDataLocal.push({
  897. key: t('日志详情'),
  898. value: other?.claude
  899. ? renderClaudeLogContent(
  900. other?.model_ratio,
  901. other.completion_ratio,
  902. other.model_price,
  903. other.group_ratio,
  904. other.cache_ratio || 1.0,
  905. other.cache_creation_ratio || 1.0,
  906. )
  907. : renderLogContent(
  908. other?.model_ratio,
  909. other.completion_ratio,
  910. other.model_price,
  911. other.group_ratio,
  912. other?.user_group_ratio,
  913. false,
  914. 1.0,
  915. undefined,
  916. other.web_search || false,
  917. other.web_search_call_count || 0,
  918. other.file_search || false,
  919. other.file_search_call_count || 0,
  920. ),
  921. });
  922. }
  923. if (logs[i].type === 2) {
  924. let modelMapped =
  925. other?.is_model_mapped &&
  926. other?.upstream_model_name &&
  927. other?.upstream_model_name !== '';
  928. if (modelMapped) {
  929. expandDataLocal.push({
  930. key: t('请求并计费模型'),
  931. value: logs[i].model_name,
  932. });
  933. expandDataLocal.push({
  934. key: t('实际模型'),
  935. value: other.upstream_model_name,
  936. });
  937. }
  938. let content = '';
  939. if (other?.ws || other?.audio) {
  940. content = renderAudioModelPrice(
  941. other?.text_input,
  942. other?.text_output,
  943. other?.model_ratio,
  944. other?.model_price,
  945. other?.completion_ratio,
  946. other?.audio_input,
  947. other?.audio_output,
  948. other?.audio_ratio,
  949. other?.audio_completion_ratio,
  950. other?.group_ratio,
  951. other?.cache_tokens || 0,
  952. other?.cache_ratio || 1.0,
  953. );
  954. } else if (other?.claude) {
  955. content = renderClaudeModelPrice(
  956. logs[i].prompt_tokens,
  957. logs[i].completion_tokens,
  958. other.model_ratio,
  959. other.model_price,
  960. other.completion_ratio,
  961. other.group_ratio,
  962. other.cache_tokens || 0,
  963. other.cache_ratio || 1.0,
  964. other.cache_creation_tokens || 0,
  965. other.cache_creation_ratio || 1.0,
  966. );
  967. } else {
  968. content = renderModelPrice(
  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?.image || false,
  978. other?.image_ratio || 0,
  979. other?.image_output || 0,
  980. other?.web_search || false,
  981. other?.web_search_call_count || 0,
  982. other?.web_search_price || 0,
  983. other?.file_search || false,
  984. other?.file_search_call_count || 0,
  985. other?.file_search_price || 0,
  986. );
  987. }
  988. expandDataLocal.push({
  989. key: t('计费过程'),
  990. value: content,
  991. });
  992. if (other?.reasoning_effort) {
  993. expandDataLocal.push({
  994. key: t('Reasoning Effort'),
  995. value: other.reasoning_effort,
  996. });
  997. }
  998. }
  999. expandDatesLocal[logs[i].key] = expandDataLocal;
  1000. }
  1001. setExpandData(expandDatesLocal);
  1002. setLogs(logs);
  1003. };
  1004. const loadLogs = async (startIdx, pageSize, logType = 0) => {
  1005. setLoading(true);
  1006. let url = '';
  1007. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  1008. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  1009. if (isAdminUser) {
  1010. 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}`;
  1011. } else {
  1012. 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}`;
  1013. }
  1014. url = encodeURI(url);
  1015. const res = await API.get(url);
  1016. const { success, message, data } = res.data;
  1017. if (success) {
  1018. const newPageData = data.items;
  1019. setActivePage(data.page);
  1020. setPageSize(data.page_size);
  1021. setLogCount(data.total);
  1022. setLogsFormat(newPageData);
  1023. } else {
  1024. showError(message);
  1025. }
  1026. setLoading(false);
  1027. };
  1028. const handlePageChange = (page) => {
  1029. setActivePage(page);
  1030. loadLogs(page, pageSize, logType).then((r) => { });
  1031. };
  1032. const handlePageSizeChange = async (size) => {
  1033. localStorage.setItem('page-size', size + '');
  1034. setPageSize(size);
  1035. setActivePage(1);
  1036. loadLogs(activePage, size)
  1037. .then()
  1038. .catch((reason) => {
  1039. showError(reason);
  1040. });
  1041. };
  1042. const refresh = async () => {
  1043. setActivePage(1);
  1044. handleEyeClick();
  1045. await loadLogs(activePage, pageSize, logType);
  1046. };
  1047. const copyText = async (e, text) => {
  1048. e.stopPropagation();
  1049. if (await copy(text)) {
  1050. showSuccess('已复制:' + text);
  1051. } else {
  1052. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  1053. }
  1054. };
  1055. useEffect(() => {
  1056. const localPageSize =
  1057. parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
  1058. setPageSize(localPageSize);
  1059. loadLogs(activePage, localPageSize)
  1060. .then()
  1061. .catch((reason) => {
  1062. showError(reason);
  1063. });
  1064. handleEyeClick();
  1065. }, []);
  1066. const expandRowRender = (record, index) => {
  1067. return <Descriptions data={expandData[record.key]} />;
  1068. };
  1069. return (
  1070. <>
  1071. {renderColumnSelector()}
  1072. <Card
  1073. className="!rounded-2xl overflow-hidden mb-4"
  1074. title={
  1075. <div className="flex flex-col w-full">
  1076. <div className="flex flex-col md:flex-row justify-between items-center">
  1077. <div className="flex items-center text-orange-500 mb-2 md:mb-0">
  1078. <IconEyeOpened className="mr-2" />
  1079. <Text>{t('日志记录帮助您了解使用情况和排查问题')}</Text>
  1080. </div>
  1081. <Spin spinning={loadingStat}>
  1082. <div className="flex flex-wrap gap-4">
  1083. <div className="flex items-center">
  1084. <span className="text-xl mr-2">💰</span>
  1085. <div>
  1086. <Text type="tertiary" size="small">{t('消耗额度')}</Text>
  1087. <div className="font-medium">{renderQuota(stat.quota)}</div>
  1088. </div>
  1089. </div>
  1090. <div className="flex items-center">
  1091. <span className="text-xl mr-2">📊</span>
  1092. <div>
  1093. <Text type="tertiary" size="small">RPM</Text>
  1094. <div className="font-medium">{stat.rpm || 0}</div>
  1095. </div>
  1096. </div>
  1097. <div className="flex items-center">
  1098. <span className="text-xl mr-2">⚡</span>
  1099. <div>
  1100. <Text type="tertiary" size="small">TPM</Text>
  1101. <div className="font-medium">{stat.tpm || 0}</div>
  1102. </div>
  1103. </div>
  1104. </div>
  1105. </Spin>
  1106. </div>
  1107. <Divider margin="12px" />
  1108. {/* 搜索表单区域 */}
  1109. <div className="flex flex-col gap-4">
  1110. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
  1111. {/* 时间选择器 */}
  1112. <div className="col-span-1 lg:col-span-2">
  1113. <DatePicker
  1114. className="w-full"
  1115. value={[start_timestamp, end_timestamp]}
  1116. type='dateTimeRange'
  1117. onChange={(value) => {
  1118. if (Array.isArray(value) && value.length === 2) {
  1119. handleInputChange(value[0], 'start_timestamp');
  1120. handleInputChange(value[1], 'end_timestamp');
  1121. }
  1122. }}
  1123. />
  1124. </div>
  1125. {/* 日志类型选择器 */}
  1126. <Select
  1127. value={logType.toString()}
  1128. placeholder={t('日志类型')}
  1129. className="!rounded-full"
  1130. onChange={(value) => {
  1131. setLogType(parseInt(value));
  1132. loadLogs(0, pageSize, parseInt(value));
  1133. }}
  1134. >
  1135. <Select.Option value='0'>{t('全部')}</Select.Option>
  1136. <Select.Option value='1'>{t('充值')}</Select.Option>
  1137. <Select.Option value='2'>{t('消费')}</Select.Option>
  1138. <Select.Option value='3'>{t('管理')}</Select.Option>
  1139. <Select.Option value='4'>{t('系统')}</Select.Option>
  1140. <Select.Option value='5'>{t('错误')}</Select.Option>
  1141. </Select>
  1142. {/* 其他搜索字段 */}
  1143. <Input
  1144. prefix={<IconSearch />}
  1145. placeholder={t('令牌名称')}
  1146. value={token_name}
  1147. onChange={(value) => handleInputChange(value, 'token_name')}
  1148. className="!rounded-full"
  1149. showClear
  1150. />
  1151. <Input
  1152. prefix={<IconSearch />}
  1153. placeholder={t('模型名称')}
  1154. value={model_name}
  1155. onChange={(value) => handleInputChange(value, 'model_name')}
  1156. className="!rounded-full"
  1157. showClear
  1158. />
  1159. <Input
  1160. prefix={<IconSearch />}
  1161. placeholder={t('分组')}
  1162. value={group}
  1163. onChange={(value) => handleInputChange(value, 'group')}
  1164. className="!rounded-full"
  1165. showClear
  1166. />
  1167. {isAdminUser && (
  1168. <>
  1169. <Input
  1170. prefix={<IconSearch />}
  1171. placeholder={t('渠道 ID')}
  1172. value={channel}
  1173. onChange={(value) => handleInputChange(value, 'channel')}
  1174. className="!rounded-full"
  1175. showClear
  1176. />
  1177. <Input
  1178. prefix={<IconSearch />}
  1179. placeholder={t('用户名称')}
  1180. value={username}
  1181. onChange={(value) => handleInputChange(value, 'username')}
  1182. className="!rounded-full"
  1183. showClear
  1184. />
  1185. </>
  1186. )}
  1187. </div>
  1188. {/* 操作按钮区域 */}
  1189. <div className="flex justify-between items-center pt-2">
  1190. <div></div>
  1191. <div className="flex gap-2">
  1192. <Button
  1193. type='primary'
  1194. onClick={refresh}
  1195. loading={loading}
  1196. className="!rounded-full"
  1197. >
  1198. {t('查询')}
  1199. </Button>
  1200. <Button
  1201. theme='light'
  1202. type='tertiary'
  1203. icon={<IconSetting />}
  1204. onClick={() => setShowColumnSelector(true)}
  1205. className="!rounded-full"
  1206. >
  1207. {t('列设置')}
  1208. </Button>
  1209. </div>
  1210. </div>
  1211. </div>
  1212. </div>
  1213. }
  1214. shadows='hover'
  1215. >
  1216. <Table
  1217. columns={getVisibleColumns()}
  1218. expandedRowRender={expandRowRender}
  1219. expandRowByClick={true}
  1220. dataSource={logs}
  1221. rowKey='key'
  1222. loading={loading}
  1223. className="rounded-xl overflow-hidden"
  1224. size="middle"
  1225. pagination={{
  1226. formatPageText: (page) =>
  1227. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  1228. start: page.currentStart,
  1229. end: page.currentEnd,
  1230. total: logCount,
  1231. }),
  1232. currentPage: activePage,
  1233. pageSize: pageSize,
  1234. total: logCount,
  1235. pageSizeOptions: [10, 20, 50, 100],
  1236. showSizeChanger: true,
  1237. onPageSizeChange: (size) => {
  1238. handlePageSizeChange(size);
  1239. },
  1240. onPageChange: handlePageChange,
  1241. }}
  1242. />
  1243. </Card>
  1244. </>
  1245. );
  1246. };
  1247. export default LogsTable;