| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { useState, useEffect } from 'react';
|
| import { useTranslation } from 'react-i18next';
|
| import { Modal } from '@douyinfe/semi-ui';
|
| import {
|
| API,
|
| getTodayStartTimestamp,
|
| isAdmin,
|
| showError,
|
| showSuccess,
|
| timestamp2string,
|
| renderQuota,
|
| renderNumber,
|
| getLogOther,
|
| copy,
|
| renderClaudeLogContent,
|
| renderLogContent,
|
| renderAudioModelPrice,
|
| renderClaudeModelPrice,
|
| renderModelPrice,
|
| } from '../../helpers';
|
| import { ITEMS_PER_PAGE } from '../../constants';
|
| import { useTableCompactMode } from '../common/useTableCompactMode';
|
|
|
| export const useLogsData = () => {
|
| const { t } = useTranslation();
|
|
|
|
|
| const COLUMN_KEYS = {
|
| TIME: 'time',
|
| CHANNEL: 'channel',
|
| USERNAME: 'username',
|
| TOKEN: 'token',
|
| GROUP: 'group',
|
| TYPE: 'type',
|
| MODEL: 'model',
|
| USE_TIME: 'use_time',
|
| PROMPT: 'prompt',
|
| COMPLETION: 'completion',
|
| COST: 'cost',
|
| RETRY: 'retry',
|
| IP: 'ip',
|
| DETAILS: 'details',
|
| };
|
|
|
|
|
| const [logs, setLogs] = useState([]);
|
| const [expandData, setExpandData] = useState({});
|
| const [showStat, setShowStat] = useState(false);
|
| const [loading, setLoading] = useState(false);
|
| const [loadingStat, setLoadingStat] = useState(false);
|
| const [activePage, setActivePage] = useState(1);
|
| const [logCount, setLogCount] = useState(0);
|
| const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
| const [logType, setLogType] = useState(0);
|
|
|
|
|
| const isAdminUser = isAdmin();
|
|
|
| const STORAGE_KEY = isAdminUser
|
| ? 'logs-table-columns-admin'
|
| : 'logs-table-columns-user';
|
|
|
|
|
| const [stat, setStat] = useState({
|
| quota: 0,
|
| token: 0,
|
| });
|
|
|
|
|
| const [formApi, setFormApi] = useState(null);
|
| let now = new Date();
|
| const formInitValues = {
|
| username: '',
|
| token_name: '',
|
| model_name: '',
|
| channel: '',
|
| group: '',
|
| dateRange: [
|
| timestamp2string(getTodayStartTimestamp()),
|
| timestamp2string(now.getTime() / 1000 + 3600),
|
| ],
|
| logType: '0',
|
| };
|
|
|
|
|
| const [visibleColumns, setVisibleColumns] = useState({});
|
| const [showColumnSelector, setShowColumnSelector] = useState(false);
|
|
|
|
|
| const [compactMode, setCompactMode] = useTableCompactMode('logs');
|
|
|
|
|
| const [showUserInfo, setShowUserInfoModal] = useState(false);
|
| const [userInfoData, setUserInfoData] = useState(null);
|
|
|
|
|
| const [
|
| showChannelAffinityUsageCacheModal,
|
| setShowChannelAffinityUsageCacheModal,
|
| ] = useState(false);
|
| const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
|
| useState(null);
|
|
|
|
|
| useEffect(() => {
|
| const savedColumns = localStorage.getItem(STORAGE_KEY);
|
| if (savedColumns) {
|
| try {
|
| const parsed = JSON.parse(savedColumns);
|
| const defaults = getDefaultColumnVisibility();
|
| const merged = { ...defaults, ...parsed };
|
|
|
|
|
| if (!isAdminUser) {
|
| merged[COLUMN_KEYS.CHANNEL] = false;
|
| merged[COLUMN_KEYS.USERNAME] = false;
|
| merged[COLUMN_KEYS.RETRY] = false;
|
| }
|
| setVisibleColumns(merged);
|
| } catch (e) {
|
| console.error('Failed to parse saved column preferences', e);
|
| initDefaultColumns();
|
| }
|
| } else {
|
| initDefaultColumns();
|
| }
|
| }, []);
|
|
|
|
|
| const getDefaultColumnVisibility = () => {
|
| return {
|
| [COLUMN_KEYS.TIME]: true,
|
| [COLUMN_KEYS.CHANNEL]: isAdminUser,
|
| [COLUMN_KEYS.USERNAME]: isAdminUser,
|
| [COLUMN_KEYS.TOKEN]: true,
|
| [COLUMN_KEYS.GROUP]: true,
|
| [COLUMN_KEYS.TYPE]: true,
|
| [COLUMN_KEYS.MODEL]: true,
|
| [COLUMN_KEYS.USE_TIME]: true,
|
| [COLUMN_KEYS.PROMPT]: true,
|
| [COLUMN_KEYS.COMPLETION]: true,
|
| [COLUMN_KEYS.COST]: true,
|
| [COLUMN_KEYS.RETRY]: isAdminUser,
|
| [COLUMN_KEYS.IP]: true,
|
| [COLUMN_KEYS.DETAILS]: true,
|
| };
|
| };
|
|
|
|
|
| const initDefaultColumns = () => {
|
| const defaults = getDefaultColumnVisibility();
|
| setVisibleColumns(defaults);
|
| localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
|
| };
|
|
|
|
|
| const handleColumnVisibilityChange = (columnKey, checked) => {
|
| const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
| setVisibleColumns(updatedColumns);
|
| };
|
|
|
|
|
| const handleSelectAll = (checked) => {
|
| const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
| const updatedColumns = {};
|
|
|
| allKeys.forEach((key) => {
|
| if (
|
| (key === COLUMN_KEYS.CHANNEL ||
|
| key === COLUMN_KEYS.USERNAME ||
|
| key === COLUMN_KEYS.RETRY) &&
|
| !isAdminUser
|
| ) {
|
| updatedColumns[key] = false;
|
| } else {
|
| updatedColumns[key] = checked;
|
| }
|
| });
|
|
|
| setVisibleColumns(updatedColumns);
|
| };
|
|
|
|
|
| useEffect(() => {
|
| if (Object.keys(visibleColumns).length > 0) {
|
| localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
|
| }
|
| }, [visibleColumns]);
|
|
|
|
|
| const getFormValues = () => {
|
| const formValues = formApi ? formApi.getValues() : {};
|
|
|
| let start_timestamp = timestamp2string(getTodayStartTimestamp());
|
| let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
|
|
|
| if (
|
| formValues.dateRange &&
|
| Array.isArray(formValues.dateRange) &&
|
| formValues.dateRange.length === 2
|
| ) {
|
| start_timestamp = formValues.dateRange[0];
|
| end_timestamp = formValues.dateRange[1];
|
| }
|
|
|
| return {
|
| username: formValues.username || '',
|
| token_name: formValues.token_name || '',
|
| model_name: formValues.model_name || '',
|
| start_timestamp,
|
| end_timestamp,
|
| channel: formValues.channel || '',
|
| group: formValues.group || '',
|
| logType: formValues.logType ? parseInt(formValues.logType) : 0,
|
| };
|
| };
|
|
|
|
|
| const getLogSelfStat = async () => {
|
| const {
|
| token_name,
|
| model_name,
|
| start_timestamp,
|
| end_timestamp,
|
| group,
|
| logType: formLogType,
|
| } = getFormValues();
|
| const currentLogType = formLogType !== undefined ? formLogType : logType;
|
| let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
| let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
| let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
|
| url = encodeURI(url);
|
| let res = await API.get(url);
|
| const { success, message, data } = res.data;
|
| if (success) {
|
| setStat(data);
|
| } else {
|
| showError(message);
|
| }
|
| };
|
|
|
| const getLogStat = async () => {
|
| const {
|
| username,
|
| token_name,
|
| model_name,
|
| start_timestamp,
|
| end_timestamp,
|
| channel,
|
| group,
|
| logType: formLogType,
|
| } = getFormValues();
|
| const currentLogType = formLogType !== undefined ? formLogType : logType;
|
| let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
| let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
| 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}`;
|
| url = encodeURI(url);
|
| let res = await API.get(url);
|
| const { success, message, data } = res.data;
|
| if (success) {
|
| setStat(data);
|
| } else {
|
| showError(message);
|
| }
|
| };
|
|
|
| const handleEyeClick = async () => {
|
| if (loadingStat) {
|
| return;
|
| }
|
| setLoadingStat(true);
|
| if (isAdminUser) {
|
| await getLogStat();
|
| } else {
|
| await getLogSelfStat();
|
| }
|
| setShowStat(true);
|
| setLoadingStat(false);
|
| };
|
|
|
|
|
| const showUserInfoFunc = async (userId) => {
|
| if (!isAdminUser) {
|
| return;
|
| }
|
| const res = await API.get(`/api/user/${userId}`);
|
| const { success, message, data } = res.data;
|
| if (success) {
|
| setUserInfoData(data);
|
| setShowUserInfoModal(true);
|
| } else {
|
| showError(message);
|
| }
|
| };
|
|
|
| const openChannelAffinityUsageCacheModal = (affinity) => {
|
| const a = affinity || {};
|
| setChannelAffinityUsageCacheTarget({
|
| rule_name: a.rule_name || a.reason || '',
|
| using_group: a.using_group || '',
|
| key_hint: a.key_hint || '',
|
| key_fp: a.key_fp || '',
|
| });
|
| setShowChannelAffinityUsageCacheModal(true);
|
| };
|
|
|
|
|
| const setLogsFormat = (logs) => {
|
| const requestConversionDisplayValue = (conversionChain) => {
|
| const chain = Array.isArray(conversionChain)
|
| ? conversionChain.filter(Boolean)
|
| : [];
|
| if (chain.length <= 1) {
|
| return t('原生格式');
|
| }
|
| return `${chain.join(' -> ')}`;
|
| };
|
|
|
| let expandDatesLocal = {};
|
| for (let i = 0; i < logs.length; i++) {
|
| logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
| logs[i].key = logs[i].id;
|
| let other = getLogOther(logs[i].other);
|
| let expandDataLocal = [];
|
|
|
| if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
|
| expandDataLocal.push({
|
| key: t('渠道信息'),
|
| value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
|
| });
|
| }
|
| if (other?.ws || other?.audio) {
|
| expandDataLocal.push({
|
| key: t('语音输入'),
|
| value: other.audio_input,
|
| });
|
| expandDataLocal.push({
|
| key: t('语音输出'),
|
| value: other.audio_output,
|
| });
|
| expandDataLocal.push({
|
| key: t('文字输入'),
|
| value: other.text_input,
|
| });
|
| expandDataLocal.push({
|
| key: t('文字输出'),
|
| value: other.text_output,
|
| });
|
| }
|
| if (other?.cache_tokens > 0) {
|
| expandDataLocal.push({
|
| key: t('缓存 Tokens'),
|
| value: other.cache_tokens,
|
| });
|
| }
|
| if (other?.cache_creation_tokens > 0) {
|
| expandDataLocal.push({
|
| key: t('缓存创建 Tokens'),
|
| value: other.cache_creation_tokens,
|
| });
|
| }
|
| if (logs[i].type === 2) {
|
| expandDataLocal.push({
|
| key: t('日志详情'),
|
| value: other?.claude
|
| ? renderClaudeLogContent(
|
| other?.model_ratio,
|
| other.completion_ratio,
|
| other.model_price,
|
| other.group_ratio,
|
| other?.user_group_ratio,
|
| other.cache_ratio || 1.0,
|
| other.cache_creation_ratio || 1.0,
|
| other.cache_creation_tokens_5m || 0,
|
| other.cache_creation_ratio_5m ||
|
| other.cache_creation_ratio ||
|
| 1.0,
|
| other.cache_creation_tokens_1h || 0,
|
| other.cache_creation_ratio_1h ||
|
| other.cache_creation_ratio ||
|
| 1.0,
|
| )
|
| : renderLogContent(
|
| other?.model_ratio,
|
| other.completion_ratio,
|
| other.model_price,
|
| other.group_ratio,
|
| other?.user_group_ratio,
|
| other.cache_ratio || 1.0,
|
| false,
|
| 1.0,
|
| other.web_search || false,
|
| other.web_search_call_count || 0,
|
| other.file_search || false,
|
| other.file_search_call_count || 0,
|
| ),
|
| });
|
| if (logs[i]?.content) {
|
| expandDataLocal.push({
|
| key: t('其他详情'),
|
| value: logs[i].content,
|
| });
|
| }
|
| if (isAdminUser && other?.reject_reason) {
|
| expandDataLocal.push({
|
| key: t('拦截原因'),
|
| value: other.reject_reason,
|
| });
|
| }
|
| }
|
| if (logs[i].type === 2) {
|
| let modelMapped =
|
| other?.is_model_mapped &&
|
| other?.upstream_model_name &&
|
| other?.upstream_model_name !== '';
|
| if (modelMapped) {
|
| expandDataLocal.push({
|
| key: t('请求并计费模型'),
|
| value: logs[i].model_name,
|
| });
|
| expandDataLocal.push({
|
| key: t('实际模型'),
|
| value: other.upstream_model_name,
|
| });
|
| }
|
|
|
| const isViolationFeeLog =
|
| other?.violation_fee === true ||
|
| Boolean(other?.violation_fee_code) ||
|
| Boolean(other?.violation_fee_marker);
|
|
|
| let content = '';
|
| if (!isViolationFeeLog) {
|
| if (other?.ws || other?.audio) {
|
| content = renderAudioModelPrice(
|
| other?.text_input,
|
| other?.text_output,
|
| other?.model_ratio,
|
| other?.model_price,
|
| other?.completion_ratio,
|
| other?.audio_input,
|
| other?.audio_output,
|
| other?.audio_ratio,
|
| other?.audio_completion_ratio,
|
| other?.group_ratio,
|
| other?.user_group_ratio,
|
| other?.cache_tokens || 0,
|
| other?.cache_ratio || 1.0,
|
| );
|
| } else if (other?.claude) {
|
| content = renderClaudeModelPrice(
|
| logs[i].prompt_tokens,
|
| logs[i].completion_tokens,
|
| other.model_ratio,
|
| other.model_price,
|
| other.completion_ratio,
|
| other.group_ratio,
|
| other?.user_group_ratio,
|
| other.cache_tokens || 0,
|
| other.cache_ratio || 1.0,
|
| other.cache_creation_tokens || 0,
|
| other.cache_creation_ratio || 1.0,
|
| other.cache_creation_tokens_5m || 0,
|
| other.cache_creation_ratio_5m ||
|
| other.cache_creation_ratio ||
|
| 1.0,
|
| other.cache_creation_tokens_1h || 0,
|
| other.cache_creation_ratio_1h ||
|
| other.cache_creation_ratio ||
|
| 1.0,
|
| );
|
| } else {
|
| content = renderModelPrice(
|
| logs[i].prompt_tokens,
|
| logs[i].completion_tokens,
|
| other?.model_ratio,
|
| other?.model_price,
|
| other?.completion_ratio,
|
| other?.group_ratio,
|
| other?.user_group_ratio,
|
| other?.cache_tokens || 0,
|
| other?.cache_ratio || 1.0,
|
| other?.image || false,
|
| other?.image_ratio || 0,
|
| other?.image_output || 0,
|
| other?.web_search || false,
|
| other?.web_search_call_count || 0,
|
| other?.web_search_price || 0,
|
| other?.file_search || false,
|
| other?.file_search_call_count || 0,
|
| other?.file_search_price || 0,
|
| other?.audio_input_seperate_price || false,
|
| other?.audio_input_token_count || 0,
|
| other?.audio_input_price || 0,
|
| other?.image_generation_call || false,
|
| other?.image_generation_call_price || 0,
|
| );
|
| }
|
| expandDataLocal.push({
|
| key: t('计费过程'),
|
| value: content,
|
| });
|
| }
|
| if (other?.reasoning_effort) {
|
| expandDataLocal.push({
|
| key: t('Reasoning Effort'),
|
| value: other.reasoning_effort,
|
| });
|
| }
|
| }
|
| if (other?.request_path) {
|
| expandDataLocal.push({
|
| key: t('请求路径'),
|
| value: other.request_path,
|
| });
|
| }
|
| if (isAdminUser) {
|
| expandDataLocal.push({
|
| key: t('请求转换'),
|
| value: requestConversionDisplayValue(other?.request_conversion),
|
| });
|
| }
|
| if (isAdminUser) {
|
| let localCountMode = '';
|
| if (other?.admin_info?.local_count_tokens) {
|
| localCountMode = t('本地计费');
|
| } else {
|
| localCountMode = t('上游返回');
|
| }
|
| expandDataLocal.push({
|
| key: t('计费模式'),
|
| value: localCountMode,
|
| });
|
| }
|
| expandDatesLocal[logs[i].key] = expandDataLocal;
|
| }
|
|
|
| setExpandData(expandDatesLocal);
|
| setLogs(logs);
|
| };
|
|
|
|
|
| const loadLogs = async (startIdx, pageSize, customLogType = null) => {
|
| setLoading(true);
|
|
|
| let url = '';
|
| const {
|
| username,
|
| token_name,
|
| model_name,
|
| start_timestamp,
|
| end_timestamp,
|
| channel,
|
| group,
|
| logType: formLogType,
|
| } = getFormValues();
|
|
|
| const currentLogType =
|
| customLogType !== null
|
| ? customLogType
|
| : formLogType !== undefined
|
| ? formLogType
|
| : logType;
|
|
|
| let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
| let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
| if (isAdminUser) {
|
| 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}`;
|
| } else {
|
| 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}`;
|
| }
|
| url = encodeURI(url);
|
| const res = await API.get(url);
|
| const { success, message, data } = res.data;
|
| if (success) {
|
| const newPageData = data.items;
|
| setActivePage(data.page);
|
| setPageSize(data.page_size);
|
| setLogCount(data.total);
|
|
|
| setLogsFormat(newPageData);
|
| } else {
|
| showError(message);
|
| }
|
| setLoading(false);
|
| };
|
|
|
|
|
| const handlePageChange = (page) => {
|
| setActivePage(page);
|
| loadLogs(page, pageSize).then((r) => {});
|
| };
|
|
|
| const handlePageSizeChange = async (size) => {
|
| localStorage.setItem('page-size', size + '');
|
| setPageSize(size);
|
| setActivePage(1);
|
| loadLogs(activePage, size)
|
| .then()
|
| .catch((reason) => {
|
| showError(reason);
|
| });
|
| };
|
|
|
|
|
| const refresh = async () => {
|
| setActivePage(1);
|
| handleEyeClick();
|
| await loadLogs(1, pageSize);
|
| };
|
|
|
|
|
| const copyText = async (e, text) => {
|
| e.stopPropagation();
|
| if (await copy(text)) {
|
| showSuccess('已复制:' + text);
|
| } else {
|
| Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
| }
|
| };
|
|
|
|
|
| useEffect(() => {
|
| const localPageSize =
|
| parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
| setPageSize(localPageSize);
|
| loadLogs(activePage, localPageSize)
|
| .then()
|
| .catch((reason) => {
|
| showError(reason);
|
| });
|
| }, []);
|
|
|
|
|
| useEffect(() => {
|
| if (formApi) {
|
| handleEyeClick();
|
| }
|
| }, [formApi]);
|
|
|
|
|
| const hasExpandableRows = () => {
|
| return logs.some(
|
| (log) => expandData[log.key] && expandData[log.key].length > 0,
|
| );
|
| };
|
|
|
| return {
|
|
|
| logs,
|
| expandData,
|
| showStat,
|
| loading,
|
| loadingStat,
|
| activePage,
|
| logCount,
|
| pageSize,
|
| logType,
|
| stat,
|
| isAdminUser,
|
|
|
|
|
| formApi,
|
| setFormApi,
|
| formInitValues,
|
| getFormValues,
|
|
|
|
|
| visibleColumns,
|
| showColumnSelector,
|
| setShowColumnSelector,
|
| handleColumnVisibilityChange,
|
| handleSelectAll,
|
| initDefaultColumns,
|
| COLUMN_KEYS,
|
|
|
|
|
| compactMode,
|
| setCompactMode,
|
|
|
|
|
| showUserInfo,
|
| setShowUserInfoModal,
|
| userInfoData,
|
| showUserInfoFunc,
|
|
|
|
|
| showChannelAffinityUsageCacheModal,
|
| setShowChannelAffinityUsageCacheModal,
|
| channelAffinityUsageCacheTarget,
|
| openChannelAffinityUsageCacheModal,
|
|
|
|
|
| loadLogs,
|
| handlePageChange,
|
| handlePageSizeChange,
|
| refresh,
|
| copyText,
|
| handleEyeClick,
|
| setLogsFormat,
|
| hasExpandableRows,
|
| setLogType,
|
|
|
|
|
| t,
|
| };
|
| };
|
|
|