useUsageLogsData.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import { useState, useEffect } from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import { Modal } from '@douyinfe/semi-ui';
  18. import {
  19. API,
  20. getTodayStartTimestamp,
  21. isAdmin,
  22. showError,
  23. showSuccess,
  24. timestamp2string,
  25. renderQuota,
  26. renderNumber,
  27. getLogOther,
  28. copy,
  29. renderClaudeLogContent,
  30. renderLogContent,
  31. renderAudioModelPrice,
  32. renderClaudeModelPrice,
  33. renderModelPrice
  34. } from '../../helpers';
  35. import { ITEMS_PER_PAGE } from '../../constants';
  36. import { useTableCompactMode } from '../common/useTableCompactMode';
  37. export const useLogsData = () => {
  38. const { t } = useTranslation();
  39. // Define column keys for selection
  40. const COLUMN_KEYS = {
  41. TIME: 'time',
  42. CHANNEL: 'channel',
  43. USERNAME: 'username',
  44. TOKEN: 'token',
  45. GROUP: 'group',
  46. TYPE: 'type',
  47. MODEL: 'model',
  48. USE_TIME: 'use_time',
  49. PROMPT: 'prompt',
  50. COMPLETION: 'completion',
  51. COST: 'cost',
  52. RETRY: 'retry',
  53. IP: 'ip',
  54. DETAILS: 'details',
  55. };
  56. // Basic state
  57. const [logs, setLogs] = useState([]);
  58. const [expandData, setExpandData] = useState({});
  59. const [showStat, setShowStat] = useState(false);
  60. const [loading, setLoading] = useState(false);
  61. const [loadingStat, setLoadingStat] = useState(false);
  62. const [activePage, setActivePage] = useState(1);
  63. const [logCount, setLogCount] = useState(0);
  64. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  65. const [logType, setLogType] = useState(0);
  66. // User and admin
  67. const isAdminUser = isAdmin();
  68. // Role-specific storage key to prevent different roles from overwriting each other
  69. const STORAGE_KEY = isAdminUser ? 'logs-table-columns-admin' : 'logs-table-columns-user';
  70. // Statistics state
  71. const [stat, setStat] = useState({
  72. quota: 0,
  73. token: 0,
  74. });
  75. // Form state
  76. const [formApi, setFormApi] = useState(null);
  77. let now = new Date();
  78. const formInitValues = {
  79. username: '',
  80. token_name: '',
  81. model_name: '',
  82. channel: '',
  83. group: '',
  84. dateRange: [
  85. timestamp2string(getTodayStartTimestamp()),
  86. timestamp2string(now.getTime() / 1000 + 3600),
  87. ],
  88. logType: '0',
  89. };
  90. // Column visibility state
  91. const [visibleColumns, setVisibleColumns] = useState({});
  92. const [showColumnSelector, setShowColumnSelector] = useState(false);
  93. // Compact mode
  94. const [compactMode, setCompactMode] = useTableCompactMode('logs');
  95. // User info modal state
  96. const [showUserInfo, setShowUserInfoModal] = useState(false);
  97. const [userInfoData, setUserInfoData] = useState(null);
  98. // Load saved column preferences from localStorage
  99. useEffect(() => {
  100. const savedColumns = localStorage.getItem(STORAGE_KEY);
  101. if (savedColumns) {
  102. try {
  103. const parsed = JSON.parse(savedColumns);
  104. const defaults = getDefaultColumnVisibility();
  105. const merged = { ...defaults, ...parsed };
  106. // For non-admin users, force-hide admin-only columns (does not touch admin settings)
  107. if (!isAdminUser) {
  108. merged[COLUMN_KEYS.CHANNEL] = false;
  109. merged[COLUMN_KEYS.USERNAME] = false;
  110. merged[COLUMN_KEYS.RETRY] = false;
  111. }
  112. setVisibleColumns(merged);
  113. } catch (e) {
  114. console.error('Failed to parse saved column preferences', e);
  115. initDefaultColumns();
  116. }
  117. } else {
  118. initDefaultColumns();
  119. }
  120. }, []);
  121. // Get default column visibility based on user role
  122. const getDefaultColumnVisibility = () => {
  123. return {
  124. [COLUMN_KEYS.TIME]: true,
  125. [COLUMN_KEYS.CHANNEL]: isAdminUser,
  126. [COLUMN_KEYS.USERNAME]: isAdminUser,
  127. [COLUMN_KEYS.TOKEN]: true,
  128. [COLUMN_KEYS.GROUP]: true,
  129. [COLUMN_KEYS.TYPE]: true,
  130. [COLUMN_KEYS.MODEL]: true,
  131. [COLUMN_KEYS.USE_TIME]: true,
  132. [COLUMN_KEYS.PROMPT]: true,
  133. [COLUMN_KEYS.COMPLETION]: true,
  134. [COLUMN_KEYS.COST]: true,
  135. [COLUMN_KEYS.RETRY]: isAdminUser,
  136. [COLUMN_KEYS.IP]: true,
  137. [COLUMN_KEYS.DETAILS]: true,
  138. };
  139. };
  140. // Initialize default column visibility
  141. const initDefaultColumns = () => {
  142. const defaults = getDefaultColumnVisibility();
  143. setVisibleColumns(defaults);
  144. localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
  145. };
  146. // Handle column visibility change
  147. const handleColumnVisibilityChange = (columnKey, checked) => {
  148. const updatedColumns = { ...visibleColumns, [columnKey]: checked };
  149. setVisibleColumns(updatedColumns);
  150. };
  151. // Handle "Select All" checkbox
  152. const handleSelectAll = (checked) => {
  153. const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
  154. const updatedColumns = {};
  155. allKeys.forEach((key) => {
  156. if (
  157. (key === COLUMN_KEYS.CHANNEL ||
  158. key === COLUMN_KEYS.USERNAME ||
  159. key === COLUMN_KEYS.RETRY) &&
  160. !isAdminUser
  161. ) {
  162. updatedColumns[key] = false;
  163. } else {
  164. updatedColumns[key] = checked;
  165. }
  166. });
  167. setVisibleColumns(updatedColumns);
  168. };
  169. // Persist column settings to the role-specific STORAGE_KEY
  170. useEffect(() => {
  171. if (Object.keys(visibleColumns).length > 0) {
  172. localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
  173. }
  174. }, [visibleColumns]);
  175. // 获取表单值的辅助函数,确保所有值都是字符串
  176. const getFormValues = () => {
  177. const formValues = formApi ? formApi.getValues() : {};
  178. let start_timestamp = timestamp2string(getTodayStartTimestamp());
  179. let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
  180. if (
  181. formValues.dateRange &&
  182. Array.isArray(formValues.dateRange) &&
  183. formValues.dateRange.length === 2
  184. ) {
  185. start_timestamp = formValues.dateRange[0];
  186. end_timestamp = formValues.dateRange[1];
  187. }
  188. return {
  189. username: formValues.username || '',
  190. token_name: formValues.token_name || '',
  191. model_name: formValues.model_name || '',
  192. start_timestamp,
  193. end_timestamp,
  194. channel: formValues.channel || '',
  195. group: formValues.group || '',
  196. logType: formValues.logType ? parseInt(formValues.logType) : 0,
  197. };
  198. };
  199. // Statistics functions
  200. const getLogSelfStat = async () => {
  201. const {
  202. token_name,
  203. model_name,
  204. start_timestamp,
  205. end_timestamp,
  206. group,
  207. logType: formLogType,
  208. } = getFormValues();
  209. const currentLogType = formLogType !== undefined ? formLogType : logType;
  210. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  211. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  212. let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
  213. url = encodeURI(url);
  214. let res = await API.get(url);
  215. const { success, message, data } = res.data;
  216. if (success) {
  217. setStat(data);
  218. } else {
  219. showError(message);
  220. }
  221. };
  222. const getLogStat = async () => {
  223. const {
  224. username,
  225. token_name,
  226. model_name,
  227. start_timestamp,
  228. end_timestamp,
  229. channel,
  230. group,
  231. logType: formLogType,
  232. } = getFormValues();
  233. const currentLogType = formLogType !== undefined ? formLogType : logType;
  234. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  235. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  236. 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}`;
  237. url = encodeURI(url);
  238. let res = await API.get(url);
  239. const { success, message, data } = res.data;
  240. if (success) {
  241. setStat(data);
  242. } else {
  243. showError(message);
  244. }
  245. };
  246. const handleEyeClick = async () => {
  247. if (loadingStat) {
  248. return;
  249. }
  250. setLoadingStat(true);
  251. if (isAdminUser) {
  252. await getLogStat();
  253. } else {
  254. await getLogSelfStat();
  255. }
  256. setShowStat(true);
  257. setLoadingStat(false);
  258. };
  259. // User info function
  260. const showUserInfoFunc = async (userId) => {
  261. if (!isAdminUser) {
  262. return;
  263. }
  264. const res = await API.get(`/api/user/${userId}`);
  265. const { success, message, data } = res.data;
  266. if (success) {
  267. setUserInfoData(data);
  268. setShowUserInfoModal(true);
  269. } else {
  270. showError(message);
  271. }
  272. };
  273. // Format logs data
  274. const setLogsFormat = (logs) => {
  275. let expandDatesLocal = {};
  276. for (let i = 0; i < logs.length; i++) {
  277. logs[i].timestamp2string = timestamp2string(logs[i].created_at);
  278. logs[i].key = logs[i].id;
  279. let other = getLogOther(logs[i].other);
  280. let expandDataLocal = [];
  281. if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
  282. expandDataLocal.push({
  283. key: t('渠道信息'),
  284. value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
  285. });
  286. }
  287. if (other?.ws || other?.audio) {
  288. expandDataLocal.push({
  289. key: t('语音输入'),
  290. value: other.audio_input,
  291. });
  292. expandDataLocal.push({
  293. key: t('语音输出'),
  294. value: other.audio_output,
  295. });
  296. expandDataLocal.push({
  297. key: t('文字输入'),
  298. value: other.text_input,
  299. });
  300. expandDataLocal.push({
  301. key: t('文字输出'),
  302. value: other.text_output,
  303. });
  304. }
  305. if (other?.cache_tokens > 0) {
  306. expandDataLocal.push({
  307. key: t('缓存 Tokens'),
  308. value: other.cache_tokens,
  309. });
  310. }
  311. if (other?.cache_creation_tokens > 0) {
  312. expandDataLocal.push({
  313. key: t('缓存创建 Tokens'),
  314. value: other.cache_creation_tokens,
  315. });
  316. }
  317. if (logs[i].type === 2) {
  318. expandDataLocal.push({
  319. key: t('日志详情'),
  320. value: other?.claude
  321. ? renderClaudeLogContent(
  322. other?.model_ratio,
  323. other.completion_ratio,
  324. other.model_price,
  325. other.group_ratio,
  326. other?.user_group_ratio,
  327. other.cache_ratio || 1.0,
  328. other.cache_creation_ratio || 1.0,
  329. )
  330. : renderLogContent(
  331. other?.model_ratio,
  332. other.completion_ratio,
  333. other.model_price,
  334. other.group_ratio,
  335. other?.user_group_ratio,
  336. other.cache_ratio || 1.0,
  337. false,
  338. 1.0,
  339. other.web_search || false,
  340. other.web_search_call_count || 0,
  341. other.file_search || false,
  342. other.file_search_call_count || 0,
  343. ),
  344. });
  345. }
  346. if (logs[i].type === 2) {
  347. let modelMapped =
  348. other?.is_model_mapped &&
  349. other?.upstream_model_name &&
  350. other?.upstream_model_name !== '';
  351. if (modelMapped) {
  352. expandDataLocal.push({
  353. key: t('请求并计费模型'),
  354. value: logs[i].model_name,
  355. });
  356. expandDataLocal.push({
  357. key: t('实际模型'),
  358. value: other.upstream_model_name,
  359. });
  360. }
  361. let content = '';
  362. if (other?.ws || other?.audio) {
  363. content = renderAudioModelPrice(
  364. other?.text_input,
  365. other?.text_output,
  366. other?.model_ratio,
  367. other?.model_price,
  368. other?.completion_ratio,
  369. other?.audio_input,
  370. other?.audio_output,
  371. other?.audio_ratio,
  372. other?.audio_completion_ratio,
  373. other?.group_ratio,
  374. other?.user_group_ratio,
  375. other?.cache_tokens || 0,
  376. other?.cache_ratio || 1.0,
  377. );
  378. } else if (other?.claude) {
  379. content = renderClaudeModelPrice(
  380. logs[i].prompt_tokens,
  381. logs[i].completion_tokens,
  382. other.model_ratio,
  383. other.model_price,
  384. other.completion_ratio,
  385. other.group_ratio,
  386. other?.user_group_ratio,
  387. other.cache_tokens || 0,
  388. other.cache_ratio || 1.0,
  389. other.cache_creation_tokens || 0,
  390. other.cache_creation_ratio || 1.0,
  391. );
  392. } else {
  393. content = renderModelPrice(
  394. logs[i].prompt_tokens,
  395. logs[i].completion_tokens,
  396. other?.model_ratio,
  397. other?.model_price,
  398. other?.completion_ratio,
  399. other?.group_ratio,
  400. other?.user_group_ratio,
  401. other?.cache_tokens || 0,
  402. other?.cache_ratio || 1.0,
  403. other?.image || false,
  404. other?.image_ratio || 0,
  405. other?.image_output || 0,
  406. other?.web_search || false,
  407. other?.web_search_call_count || 0,
  408. other?.web_search_price || 0,
  409. other?.file_search || false,
  410. other?.file_search_call_count || 0,
  411. other?.file_search_price || 0,
  412. other?.audio_input_seperate_price || false,
  413. other?.audio_input_token_count || 0,
  414. other?.audio_input_price || 0,
  415. );
  416. }
  417. expandDataLocal.push({
  418. key: t('计费过程'),
  419. value: content,
  420. });
  421. if (other?.reasoning_effort) {
  422. expandDataLocal.push({
  423. key: t('Reasoning Effort'),
  424. value: other.reasoning_effort,
  425. });
  426. }
  427. }
  428. expandDatesLocal[logs[i].key] = expandDataLocal;
  429. }
  430. setExpandData(expandDatesLocal);
  431. setLogs(logs);
  432. };
  433. // Load logs function
  434. const loadLogs = async (startIdx, pageSize, customLogType = null) => {
  435. setLoading(true);
  436. let url = '';
  437. const {
  438. username,
  439. token_name,
  440. model_name,
  441. start_timestamp,
  442. end_timestamp,
  443. channel,
  444. group,
  445. logType: formLogType,
  446. } = getFormValues();
  447. const currentLogType =
  448. customLogType !== null
  449. ? customLogType
  450. : formLogType !== undefined
  451. ? formLogType
  452. : logType;
  453. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  454. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  455. if (isAdminUser) {
  456. 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}`;
  457. } else {
  458. 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}`;
  459. }
  460. url = encodeURI(url);
  461. const res = await API.get(url);
  462. const { success, message, data } = res.data;
  463. if (success) {
  464. const newPageData = data.items;
  465. setActivePage(data.page);
  466. setPageSize(data.page_size);
  467. setLogCount(data.total);
  468. setLogsFormat(newPageData);
  469. } else {
  470. showError(message);
  471. }
  472. setLoading(false);
  473. };
  474. // Page handlers
  475. const handlePageChange = (page) => {
  476. setActivePage(page);
  477. loadLogs(page, pageSize).then((r) => { });
  478. };
  479. const handlePageSizeChange = async (size) => {
  480. localStorage.setItem('page-size', size + '');
  481. setPageSize(size);
  482. setActivePage(1);
  483. loadLogs(activePage, size)
  484. .then()
  485. .catch((reason) => {
  486. showError(reason);
  487. });
  488. };
  489. // Refresh function
  490. const refresh = async () => {
  491. setActivePage(1);
  492. handleEyeClick();
  493. await loadLogs(1, pageSize);
  494. };
  495. // Copy text function
  496. const copyText = async (e, text) => {
  497. e.stopPropagation();
  498. if (await copy(text)) {
  499. showSuccess('已复制:' + text);
  500. } else {
  501. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  502. }
  503. };
  504. // Initialize data
  505. useEffect(() => {
  506. const localPageSize =
  507. parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
  508. setPageSize(localPageSize);
  509. loadLogs(activePage, localPageSize)
  510. .then()
  511. .catch((reason) => {
  512. showError(reason);
  513. });
  514. }, []);
  515. // Initialize statistics when formApi is available
  516. useEffect(() => {
  517. if (formApi) {
  518. handleEyeClick();
  519. }
  520. }, [formApi]);
  521. // Check if any record has expandable content
  522. const hasExpandableRows = () => {
  523. return logs.some(
  524. (log) => expandData[log.key] && expandData[log.key].length > 0,
  525. );
  526. };
  527. return {
  528. // Basic state
  529. logs,
  530. expandData,
  531. showStat,
  532. loading,
  533. loadingStat,
  534. activePage,
  535. logCount,
  536. pageSize,
  537. logType,
  538. stat,
  539. isAdminUser,
  540. // Form state
  541. formApi,
  542. setFormApi,
  543. formInitValues,
  544. getFormValues,
  545. // Column visibility
  546. visibleColumns,
  547. showColumnSelector,
  548. setShowColumnSelector,
  549. handleColumnVisibilityChange,
  550. handleSelectAll,
  551. initDefaultColumns,
  552. COLUMN_KEYS,
  553. // Compact mode
  554. compactMode,
  555. setCompactMode,
  556. // User info modal
  557. showUserInfo,
  558. setShowUserInfoModal,
  559. userInfoData,
  560. showUserInfoFunc,
  561. // Functions
  562. loadLogs,
  563. handlePageChange,
  564. handlePageSizeChange,
  565. refresh,
  566. copyText,
  567. handleEyeClick,
  568. setLogsFormat,
  569. hasExpandableRows,
  570. setLogType,
  571. // Translation
  572. t,
  573. };
  574. };