LogsTable.js 40 KB

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