LogsTable.js 42 KB

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