LogsTable.js 38 KB

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