useUsageLogsData.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  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. false,
  337. 1.0,
  338. other.web_search || false,
  339. other.web_search_call_count || 0,
  340. other.file_search || false,
  341. other.file_search_call_count || 0,
  342. ),
  343. });
  344. }
  345. if (logs[i].type === 2) {
  346. let modelMapped =
  347. other?.is_model_mapped &&
  348. other?.upstream_model_name &&
  349. other?.upstream_model_name !== '';
  350. if (modelMapped) {
  351. expandDataLocal.push({
  352. key: t('请求并计费模型'),
  353. value: logs[i].model_name,
  354. });
  355. expandDataLocal.push({
  356. key: t('实际模型'),
  357. value: other.upstream_model_name,
  358. });
  359. }
  360. let content = '';
  361. if (other?.ws || other?.audio) {
  362. content = renderAudioModelPrice(
  363. other?.text_input,
  364. other?.text_output,
  365. other?.model_ratio,
  366. other?.model_price,
  367. other?.completion_ratio,
  368. other?.audio_input,
  369. other?.audio_output,
  370. other?.audio_ratio,
  371. other?.audio_completion_ratio,
  372. other?.group_ratio,
  373. other?.user_group_ratio,
  374. other?.cache_tokens || 0,
  375. other?.cache_ratio || 1.0,
  376. );
  377. } else if (other?.claude) {
  378. content = renderClaudeModelPrice(
  379. logs[i].prompt_tokens,
  380. logs[i].completion_tokens,
  381. other.model_ratio,
  382. other.model_price,
  383. other.completion_ratio,
  384. other.group_ratio,
  385. other?.user_group_ratio,
  386. other.cache_tokens || 0,
  387. other.cache_ratio || 1.0,
  388. other.cache_creation_tokens || 0,
  389. other.cache_creation_ratio || 1.0,
  390. );
  391. } else {
  392. content = renderModelPrice(
  393. logs[i].prompt_tokens,
  394. logs[i].completion_tokens,
  395. other?.model_ratio,
  396. other?.model_price,
  397. other?.completion_ratio,
  398. other?.group_ratio,
  399. other?.user_group_ratio,
  400. other?.cache_tokens || 0,
  401. other?.cache_ratio || 1.0,
  402. other?.image || false,
  403. other?.image_ratio || 0,
  404. other?.image_output || 0,
  405. other?.web_search || false,
  406. other?.web_search_call_count || 0,
  407. other?.web_search_price || 0,
  408. other?.file_search || false,
  409. other?.file_search_call_count || 0,
  410. other?.file_search_price || 0,
  411. other?.audio_input_seperate_price || false,
  412. other?.audio_input_token_count || 0,
  413. other?.audio_input_price || 0,
  414. );
  415. }
  416. expandDataLocal.push({
  417. key: t('计费过程'),
  418. value: content,
  419. });
  420. if (other?.reasoning_effort) {
  421. expandDataLocal.push({
  422. key: t('Reasoning Effort'),
  423. value: other.reasoning_effort,
  424. });
  425. }
  426. }
  427. expandDatesLocal[logs[i].key] = expandDataLocal;
  428. }
  429. setExpandData(expandDatesLocal);
  430. setLogs(logs);
  431. };
  432. // Load logs function
  433. const loadLogs = async (startIdx, pageSize, customLogType = null) => {
  434. setLoading(true);
  435. let url = '';
  436. const {
  437. username,
  438. token_name,
  439. model_name,
  440. start_timestamp,
  441. end_timestamp,
  442. channel,
  443. group,
  444. logType: formLogType,
  445. } = getFormValues();
  446. const currentLogType =
  447. customLogType !== null
  448. ? customLogType
  449. : formLogType !== undefined
  450. ? formLogType
  451. : logType;
  452. let localStartTimestamp = Date.parse(start_timestamp) / 1000;
  453. let localEndTimestamp = Date.parse(end_timestamp) / 1000;
  454. if (isAdminUser) {
  455. 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}`;
  456. } else {
  457. 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}`;
  458. }
  459. url = encodeURI(url);
  460. const res = await API.get(url);
  461. const { success, message, data } = res.data;
  462. if (success) {
  463. const newPageData = data.items;
  464. setActivePage(data.page);
  465. setPageSize(data.page_size);
  466. setLogCount(data.total);
  467. setLogsFormat(newPageData);
  468. } else {
  469. showError(message);
  470. }
  471. setLoading(false);
  472. };
  473. // Page handlers
  474. const handlePageChange = (page) => {
  475. setActivePage(page);
  476. loadLogs(page, pageSize).then((r) => { });
  477. };
  478. const handlePageSizeChange = async (size) => {
  479. localStorage.setItem('page-size', size + '');
  480. setPageSize(size);
  481. setActivePage(1);
  482. loadLogs(activePage, size)
  483. .then()
  484. .catch((reason) => {
  485. showError(reason);
  486. });
  487. };
  488. // Refresh function
  489. const refresh = async () => {
  490. setActivePage(1);
  491. handleEyeClick();
  492. await loadLogs(1, pageSize);
  493. };
  494. // Copy text function
  495. const copyText = async (e, text) => {
  496. e.stopPropagation();
  497. if (await copy(text)) {
  498. showSuccess('已复制:' + text);
  499. } else {
  500. Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
  501. }
  502. };
  503. // Initialize data
  504. useEffect(() => {
  505. const localPageSize =
  506. parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
  507. setPageSize(localPageSize);
  508. loadLogs(activePage, localPageSize)
  509. .then()
  510. .catch((reason) => {
  511. showError(reason);
  512. });
  513. }, []);
  514. // Initialize statistics when formApi is available
  515. useEffect(() => {
  516. if (formApi) {
  517. handleEyeClick();
  518. }
  519. }, [formApi]);
  520. // Check if any record has expandable content
  521. const hasExpandableRows = () => {
  522. return logs.some(
  523. (log) => expandData[log.key] && expandData[log.key].length > 0,
  524. );
  525. };
  526. return {
  527. // Basic state
  528. logs,
  529. expandData,
  530. showStat,
  531. loading,
  532. loadingStat,
  533. activePage,
  534. logCount,
  535. pageSize,
  536. logType,
  537. stat,
  538. isAdminUser,
  539. // Form state
  540. formApi,
  541. setFormApi,
  542. formInitValues,
  543. getFormValues,
  544. // Column visibility
  545. visibleColumns,
  546. showColumnSelector,
  547. setShowColumnSelector,
  548. handleColumnVisibilityChange,
  549. handleSelectAll,
  550. initDefaultColumns,
  551. COLUMN_KEYS,
  552. // Compact mode
  553. compactMode,
  554. setCompactMode,
  555. // User info modal
  556. showUserInfo,
  557. setShowUserInfoModal,
  558. userInfoData,
  559. showUserInfoFunc,
  560. // Functions
  561. loadLogs,
  562. handlePageChange,
  563. handlePageSizeChange,
  564. refresh,
  565. copyText,
  566. handleEyeClick,
  567. setLogsFormat,
  568. hasExpandableRows,
  569. setLogType,
  570. // Translation
  571. t,
  572. };
  573. };