| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useContext, useEffect, useState } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { |
| API, |
| copy, |
| getTodayStartTimestamp, |
| isAdmin, |
| showError, |
| showSuccess, |
| timestamp2string, |
| } from '../helpers'; |
|
|
| import { |
| Avatar, |
| Button, |
| Descriptions, |
| Form, |
| Layout, |
| Modal, |
| Popover, |
| Select, |
| Space, |
| Spin, |
| Table, |
| Tag, |
| Tooltip, |
| Checkbox, |
| } from '@douyinfe/semi-ui'; |
| import { ITEMS_PER_PAGE } from '../constants'; |
| import { |
| renderAudioModelPrice, |
| renderClaudeLogContent, |
| renderClaudeModelPrice, |
| renderClaudeModelPriceSimple, |
| renderGroup, |
| renderLogContent, |
| renderModelPrice, |
| renderModelPriceSimple, |
| renderNumber, |
| renderQuota, |
| stringToColor, |
| } from '../helpers/render'; |
| import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph'; |
| import { getLogOther } from '../helpers/other.js'; |
| import { StyleContext } from '../context/Style/index.js'; |
| import { IconInherit, IconRefresh, IconSetting } from '@douyinfe/semi-icons'; |
|
|
| const { Header } = Layout; |
|
|
| function renderTimestamp(timestamp) { |
| return <>{timestamp2string(timestamp)}</>; |
| } |
|
|
| const MODE_OPTIONS = [ |
| { key: 'all', text: 'all', value: 'all' }, |
| { key: 'self', text: 'current user', value: 'self' }, |
| ]; |
|
|
| const colors = [ |
| 'amber', |
| 'blue', |
| 'cyan', |
| 'green', |
| 'grey', |
| 'indigo', |
| 'light-blue', |
| 'lime', |
| 'orange', |
| 'pink', |
| 'purple', |
| 'red', |
| 'teal', |
| 'violet', |
| 'yellow', |
| ]; |
|
|
| const LogsTable = () => { |
| const { t } = useTranslation(); |
|
|
| function renderType(type) { |
| switch (type) { |
| case 1: |
| return ( |
| <Tag color='cyan' size='large'> |
| {t('充值')} |
| </Tag> |
| ); |
| case 2: |
| return ( |
| <Tag color='lime' size='large'> |
| {t('消费')} |
| </Tag> |
| ); |
| case 3: |
| return ( |
| <Tag color='orange' size='large'> |
| {t('管理')} |
| </Tag> |
| ); |
| case 4: |
| return ( |
| <Tag color='purple' size='large'> |
| {t('系统')} |
| </Tag> |
| ); |
| case 5: |
| return ( |
| <Tag color='green' size='large'> {/* 你可以选择一个合适的颜色 */} |
| {t('签到')} {/* 前端显示的文字 */} |
| </Tag> |
| ); |
| case 6: |
| return ( |
| <Tag color='red' size='large'> |
| {t('错误')} |
| </Tag> |
| ); |
| default: |
| return ( |
| <Tag color='grey' size='large'> |
| {t('未知')} |
| </Tag> |
| ); |
| } |
| } |
|
|
| function renderIsStream(bool) { |
| if (bool) { |
| return ( |
| <Tag color='blue' size='large'> |
| {t('流')} |
| </Tag> |
| ); |
| } else { |
| return ( |
| <Tag color='purple' size='large'> |
| {t('非流')} |
| </Tag> |
| ); |
| } |
| } |
|
|
| function renderUseTime(type) { |
| const time = parseInt(type); |
| if (time < 101) { |
| return ( |
| <Tag color='green' size='large'> |
| {' '} |
| {time} s{' '} |
| </Tag> |
| ); |
| } else if (time < 300) { |
| return ( |
| <Tag color='orange' size='large'> |
| {' '} |
| {time} s{' '} |
| </Tag> |
| ); |
| } else { |
| return ( |
| <Tag color='red' size='large'> |
| {' '} |
| {time} s{' '} |
| </Tag> |
| ); |
| } |
| } |
|
|
| function renderFirstUseTime(type) { |
| let time = parseFloat(type) / 1000.0; |
| time = time.toFixed(1); |
| if (time < 3) { |
| return ( |
| <Tag color='green' size='large'> |
| {' '} |
| {time} s{' '} |
| </Tag> |
| ); |
| } else if (time < 10) { |
| return ( |
| <Tag color='orange' size='large'> |
| {' '} |
| {time} s{' '} |
| </Tag> |
| ); |
| } else { |
| return ( |
| <Tag color='red' size='large'> |
| {' '} |
| {time} s{' '} |
| </Tag> |
| ); |
| } |
| } |
|
|
| function renderModelName(record) { |
| let other = getLogOther(record.other); |
| let modelMapped = |
| other?.is_model_mapped && |
| other?.upstream_model_name && |
| other?.upstream_model_name !== ''; |
| if (!modelMapped) { |
| return ( |
| <Tag |
| color={stringToColor(record.model_name)} |
| size='large' |
| onClick={(event) => { |
| copyText(event, record.model_name).then((r) => {}); |
| }} |
| > |
| {' '} |
| {record.model_name}{' '} |
| </Tag> |
| ); |
| } else { |
| return ( |
| <> |
| <Space vertical align={'start'}> |
| <Popover |
| content={ |
| <div style={{ padding: 10 }}> |
| <Space vertical align={'start'}> |
| <Tag |
| color={stringToColor(record.model_name)} |
| size='large' |
| onClick={(event) => { |
| copyText(event, record.model_name).then((r) => {}); |
| }} |
| > |
| {t('请求并计费模型')} {record.model_name}{' '} |
| </Tag> |
| <Tag |
| color={stringToColor(other.upstream_model_name)} |
| size='large' |
| onClick={(event) => { |
| copyText(event, other.upstream_model_name).then( |
| (r) => {}, |
| ); |
| }} |
| > |
| {t('实际模型')} {other.upstream_model_name}{' '} |
| </Tag> |
| </Space> |
| </div> |
| } |
| > |
| <Tag |
| color={stringToColor(record.model_name)} |
| size='large' |
| onClick={(event) => { |
| copyText(event, record.model_name).then((r) => {}); |
| }} |
| suffixIcon={ |
| <IconRefresh |
| style={{ width: '0.8em', height: '0.8em', opacity: 0.6 }} |
| /> |
| } |
| > |
| {' '} |
| {record.model_name}{' '} |
| </Tag> |
| </Popover> |
| {/*<Tooltip content={t('实际模型')}>*/} |
| {/* <Tag*/} |
| {/* color={stringToColor(other.upstream_model_name)}*/} |
| {/* size='large'*/} |
| {/* onClick={(event) => {*/} |
| {/* copyText(event, other.upstream_model_name).then(r => {});*/} |
| {/* }}*/} |
| {/* >*/} |
| {/* {' '}{other.upstream_model_name}{' '}*/} |
| {/* </Tag>*/} |
| {/*</Tooltip>*/} |
| </Space> |
| </> |
| ); |
| } |
| } |
|
|
| |
| 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', |
| DETAILS: 'details', |
| IP: 'ip', |
| }; |
|
|
| |
| const [visibleColumns, setVisibleColumns] = useState({}); |
| const [showColumnSelector, setShowColumnSelector] = useState(false); |
|
|
| |
| useEffect(() => { |
| const savedColumns = localStorage.getItem('logs-table-columns'); |
| if (savedColumns) { |
| try { |
| const parsed = JSON.parse(savedColumns); |
| |
| const defaults = getDefaultColumnVisibility(); |
| const merged = { ...defaults, ...parsed }; |
| 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.DETAILS]: true, |
| [COLUMN_KEYS.IP]: true, |
| }; |
| }; |
|
|
| |
| const initDefaultColumns = () => { |
| const defaults = getDefaultColumnVisibility(); |
| setVisibleColumns(defaults); |
| localStorage.setItem('logs-table-columns', 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); |
| }; |
|
|
| |
| const allColumns = [ |
| { |
| key: COLUMN_KEYS.TIME, |
| title: t('时间'), |
| dataIndex: 'timestamp2string', |
| }, |
| { |
| key: COLUMN_KEYS.CHANNEL, |
| title: t('渠道'), |
| dataIndex: 'channel', |
| className: isAdmin() ? 'tableShow' : 'tableHiddle', |
| render: (text, record, index) => { |
| return isAdminUser ? ( |
| record.type === 0 || record.type === 2 || record.type === 6 ? ( |
| <div> |
| { |
| <Tag |
| color={colors[parseInt(text) % colors.length]} |
| size='large' |
| > |
| {' '} |
| {text}{' '} ({record.channel_name || '[未知]'}) |
| </Tag> |
| } |
| </div> |
| ) : ( |
| <></> |
| ) |
| ) : ( |
| <></> |
| ); |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.USERNAME, |
| title: t('用户'), |
| dataIndex: 'username', |
| className: isAdmin() ? 'tableShow' : 'tableHiddle', |
| render: (text, record, index) => { |
| return isAdminUser ? ( |
| <div> |
| <Avatar |
| size='small' |
| color={stringToColor(text)} |
| style={{ marginRight: 4 }} |
| onClick={(event) => { |
| event.stopPropagation(); |
| showUserInfo(record.user_id); |
| }} |
| > |
| {typeof text === 'string' && text.slice(0, 1)} |
| </Avatar> |
| {text} |
| </div> |
| ) : ( |
| <></> |
| ); |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.TOKEN, |
| title: t('令牌'), |
| dataIndex: 'token_name', |
| render: (text, record, index) => { |
| return record.type === 0 || record.type === 2 || record.type === 6 ? ( |
| <div> |
| <Tag |
| color='grey' |
| size='large' |
| onClick={(event) => { |
| //cancel the row click event |
| copyText(event, text); |
| }} |
| > |
| {' '} |
| {t(text)}{' '} |
| </Tag> |
| </div> |
| ) : ( |
| <></> |
| ); |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.GROUP, |
| title: t('分组'), |
| dataIndex: 'group', |
| render: (text, record, index) => { |
| if (record.type === 0 || record.type === 2 || record.type === 6) { |
| if (record.group) { |
| return <>{renderGroup(record.group)}</>; |
| } else { |
| let other = null; |
| try { |
| other = JSON.parse(record.other); |
| } catch (e) { |
| console.error( |
| `Failed to parse record.other: "${record.other}".`, |
| e, |
| ); |
| } |
| if (other === null) { |
| return <></>; |
| } |
| if (other.group !== undefined) { |
| return <>{renderGroup(other.group)}</>; |
| } else { |
| return <></>; |
| } |
| } |
| } else { |
| return <></>; |
| } |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.TYPE, |
| title: t('类型'), |
| dataIndex: 'type', |
| render: (text, record, index) => { |
| return <>{renderType(text)}</>; |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.MODEL, |
| title: t('模型'), |
| dataIndex: 'model_name', |
| render: (text, record, index) => { |
| return record.type === 0 || record.type === 2 || record.type === 6 ? ( |
| <>{renderModelName(record)}</> |
| ) : ( |
| <></> |
| ); |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.USE_TIME, |
| title: t('用时/首字'), |
| dataIndex: 'use_time', |
| render: (text, record, index) => { |
| if (record.is_stream) { |
| let other = getLogOther(record.other); |
| return ( |
| <> |
| <Space> |
| {renderUseTime(text)} |
| {renderFirstUseTime(other?.frt)} |
| {renderIsStream(record.is_stream)} |
| </Space> |
| </> |
| ); |
| } else { |
| return ( |
| <> |
| <Space> |
| {renderUseTime(text)} |
| {renderIsStream(record.is_stream)} |
| </Space> |
| </> |
| ); |
| } |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.PROMPT, |
| title: t('提示'), |
| dataIndex: 'prompt_tokens', |
| render: (text, record, index) => { |
| return record.type === 0 || record.type === 2 || record.type === 6 ? ( |
| <>{<span> {text} </span>}</> |
| ) : ( |
| <></> |
| ); |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.COMPLETION, |
| title: t('补全'), |
| dataIndex: 'completion_tokens', |
| render: (text, record, index) => { |
| return parseInt(text) > 0 && |
| (record.type === 0 || record.type === 2 || record.type === 6) ? ( |
| <>{<span> {text} </span>}</> |
| ) : ( |
| <></> |
| ); |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.COST, |
| title: t('花费'), |
| dataIndex: 'quota', |
| render: (text, record, index) => { |
| return record.type === 0 || record.type === 2 || record.type === 6 ? ( |
| <>{renderQuota(text, 6)}</> |
| ) : ( |
| <></> |
| ); |
| }, |
| }, |
|
|
| { |
| key: COLUMN_KEYS.RETRY, |
| title: t('重试'), |
| dataIndex: 'retry', |
| className: isAdmin() ? 'tableShow' : 'tableHiddle', |
| render: (text, record, index) => { |
| let content = t('渠道') + `:${record.channel}`; |
| if (record.other !== '') { |
| let other = JSON.parse(record.other); |
| if (other === null) { |
| return <></>; |
| } |
| if (other.admin_info !== undefined) { |
| if ( |
| other.admin_info.use_channel !== null && |
| other.admin_info.use_channel !== undefined && |
| other.admin_info.use_channel !== '' |
| ) { |
| |
| let useChannel = other.admin_info.use_channel; |
| let useChannelStr = useChannel.join('->'); |
| content = t('渠道') + `:${useChannelStr}`; |
| } |
| } |
| } |
| return isAdminUser ? <div>{content}</div> : <></>; |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.IP, |
| title: t('IP地址'), |
| dataIndex: 'client_ip', |
| className: 'tableShow', |
| render: (text, record, index) => { |
| const isSupportedType = record.type === 2 || record.type === 6; |
| const hasIp = text && text.trim() !== ''; |
|
|
| if (!isSupportedType || !hasIp) { |
| return <></>; |
| } |
| |
| |
| return ( |
| <Tag |
| color='blue' |
| size='large' |
| onClick={(event) => { |
| copyText(event, text); |
| }} |
| style={{ |
| fontFamily: 'monospace', |
| cursor: 'pointer' |
| }} |
| > |
| {text} |
| </Tag> |
| ); |
| }, |
| }, |
| { |
| key: COLUMN_KEYS.DETAILS, |
| title: t('详情'), |
| dataIndex: 'content', |
| render: (text, record, index) => { |
| let other = getLogOther(record.other); |
| if (other == null || record.type !== 2) { |
| return ( |
| <Paragraph |
| ellipsis={{ |
| rows: 2, |
| showTooltip: { |
| type: 'popover', |
| opts: { style: { width: 240 } }, |
| }, |
| }} |
| style={{ maxWidth: 240 }} |
| > |
| {text} |
| </Paragraph> |
| ); |
| } |
|
|
| let content = other?.claude |
| ? renderClaudeModelPriceSimple( |
| other.model_ratio, |
| other.model_price, |
| other.group_ratio, |
| other.cache_tokens || 0, |
| other.cache_ratio || 1.0, |
| other.cache_creation_tokens || 0, |
| other.cache_creation_ratio || 1.0, |
| ) |
| : renderModelPriceSimple( |
| other.model_ratio, |
| other.model_price, |
| other.group_ratio, |
| other.cache_tokens || 0, |
| other.cache_ratio || 1.0, |
| ); |
| return ( |
| <Paragraph |
| ellipsis={{ |
| rows: 2, |
| }} |
| style={{ maxWidth: 240 }} |
| > |
| {content} |
| </Paragraph> |
| ); |
| }, |
| }, |
| ]; |
|
|
| |
| useEffect(() => { |
| if (Object.keys(visibleColumns).length > 0) { |
| |
| localStorage.setItem( |
| 'logs-table-columns', |
| JSON.stringify(visibleColumns), |
| ); |
| } |
| }, [visibleColumns]); |
|
|
| |
| const getVisibleColumns = () => { |
| return allColumns.filter((column) => visibleColumns[column.key]); |
| }; |
|
|
| |
| const renderColumnSelector = () => { |
| return ( |
| <Modal |
| title={t('列设置')} |
| visible={showColumnSelector} |
| onCancel={() => setShowColumnSelector(false)} |
| footer={ |
| <> |
| <Button onClick={() => initDefaultColumns()}>{t('重置')}</Button> |
| <Button onClick={() => setShowColumnSelector(false)}> |
| {t('取消')} |
| </Button> |
| <Button type='primary' onClick={() => setShowColumnSelector(false)}> |
| {t('确定')} |
| </Button> |
| </> |
| } |
| > |
| <div style={{ marginBottom: 20 }}> |
| <Checkbox |
| checked={Object.values(visibleColumns).every((v) => v === true)} |
| indeterminate={ |
| Object.values(visibleColumns).some((v) => v === true) && |
| !Object.values(visibleColumns).every((v) => v === true) |
| } |
| onChange={(e) => handleSelectAll(e.target.checked)} |
| > |
| {t('全选')} |
| </Checkbox> |
| </div> |
| <div |
| style={{ |
| display: 'flex', |
| flexWrap: 'wrap', |
| maxHeight: '400px', |
| overflowY: 'auto', |
| border: '1px solid var(--semi-color-border)', |
| borderRadius: '6px', |
| padding: '16px', |
| }} |
| > |
| {allColumns.map((column) => { |
| // Skip admin-only columns for non-admin users |
| if ( |
| !isAdminUser && |
| (column.key === COLUMN_KEYS.CHANNEL || |
| column.key === COLUMN_KEYS.USERNAME || |
| column.key === COLUMN_KEYS.RETRY) |
| ) { |
| return null; |
| } |
| |
| return ( |
| <div |
| key={column.key} |
| style={{ width: '50%', marginBottom: 16, paddingRight: 8 }} |
| > |
| <Checkbox |
| checked={!!visibleColumns[column.key]} |
| onChange={(e) => |
| handleColumnVisibilityChange(column.key, e.target.checked) |
| } |
| > |
| {column.title} |
| </Checkbox> |
| </div> |
| ); |
| })} |
| </div> |
| </Modal> |
| ); |
| }; |
|
|
| const [styleState, styleDispatch] = useContext(StyleContext); |
| 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(ITEMS_PER_PAGE); |
| const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); |
| const [logType, setLogType] = useState(0); |
| const isAdminUser = isAdmin(); |
| let now = new Date(); |
| |
| const [inputs, setInputs] = useState({ |
| username: '', |
| token_name: '', |
| model_name: '', |
| start_timestamp: timestamp2string(getTodayStartTimestamp()), |
| end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), |
| channel: '', |
| group: '', |
| }); |
| const { |
| username, |
| token_name, |
| model_name, |
| start_timestamp, |
| end_timestamp, |
| channel, |
| group, |
| } = inputs; |
|
|
| const [stat, setStat] = useState({ |
| quota: 0, |
| token: 0, |
| token_total: 0, |
| token_input: 0, |
| token_output: 0, |
| rpm: 0, |
| tpm: 0 |
| }); |
|
|
| const handleInputChange = (value, name) => { |
| setInputs((inputs) => ({ ...inputs, [name]: value })); |
| }; |
|
|
| const getLogSelfStat = async () => { |
| let localStartTimestamp = Date.parse(start_timestamp) / 1000; |
| let localEndTimestamp = Date.parse(end_timestamp) / 1000; |
| let url = `/api/log/self/stat?type=${logType}&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(prevStat => ({ |
| ...prevStat, |
| quota: data.quota || 0, |
| rpm: data.rpm || 0, |
| tpm: data.tpm || 0 |
| })); |
| } else { |
| showError(message); |
| } |
| }; |
|
|
| const getLogStat = async () => { |
| let localStartTimestamp = Date.parse(start_timestamp) / 1000; |
| let localEndTimestamp = Date.parse(end_timestamp) / 1000; |
| let url = `/api/log/stat?type=${logType}&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(prevStat => ({ |
| ...prevStat, |
| quota: data.quota || 0, |
| rpm: data.rpm || 0, |
| tpm: data.tpm || 0 |
| })); |
| } else { |
| showError(message); |
| } |
| }; |
|
|
| const handleEyeClick = async () => { |
| if (loadingStat) { |
| return; |
| } |
| setLoadingStat(true); |
| if (isAdminUser) { |
| await getLogStat(); |
| } else { |
| await getLogSelfStat(); |
| } |
| setShowStat(true); |
| setLoadingStat(false); |
| }; |
|
|
| const showUserInfo = async (userId) => { |
| if (!isAdminUser) { |
| return; |
| } |
| const res = await API.get(`/api/user/${userId}`); |
| const { success, message, data } = res.data; |
| if (success) { |
| Modal.info({ |
| title: t('用户信息'), |
| content: ( |
| <div style={{ padding: 12 }}> |
| <p> |
| {t('用户名')}: {data.username} |
| </p> |
| <p> |
| {t('余额')}: {renderQuota(data.quota)} |
| </p> |
| <p> |
| {t('已用额度')}:{renderQuota(data.used_quota)} |
| </p> |
| <p> |
| {t('请求次数')}:{renderNumber(data.request_count)} |
| </p> |
| </div> |
| ), |
| centered: true, |
| }); |
| } else { |
| showError(message); |
| } |
| }; |
|
|
| const setLogsFormat = (logs) => { |
| let expandDatesLocal = {}; |
| |
| let totalTokens = 0; |
| let totalInputTokens = 0; |
| let totalOutputTokens = 0; |
| |
| 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); |
| |
| |
| if (logs[i].type === 0 || logs[i].type === 2) { |
| const promptTokens = parseInt(logs[i].prompt_tokens) || 0; |
| const completionTokens = parseInt(logs[i].completion_tokens) || 0; |
| totalInputTokens += promptTokens; |
| totalOutputTokens += completionTokens; |
| totalTokens += promptTokens + completionTokens; |
| } |
| |
| let expandDataLocal = []; |
| if (isAdmin()) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
| 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, |
| ) |
| : renderLogContent( |
| other?.model_ratio, |
| other.completion_ratio, |
| other.model_price, |
| other.group_ratio, |
| other.user_group_ratio, |
| ), |
| }); |
| } |
| 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, |
| }); |
| } |
| let content = ''; |
| 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?.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.cache_tokens || 0, |
| other.cache_ratio || 1.0, |
| other.cache_creation_tokens || 0, |
| 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?.cache_tokens || 0, |
| other?.cache_ratio || 1.0, |
| ); |
| } |
| expandDataLocal.push({ |
| key: t('计费过程'), |
| value: content, |
| }); |
| if (other?.reasoning_effort) { |
| expandDataLocal.push({ |
| key: t('Reasoning Effort'), |
| value: other.reasoning_effort, |
| }); |
| } |
| } |
| |
| |
| if (logs[i].type === 6) { |
| if (other?.error_type) { |
| expandDataLocal.push({ |
| key: t('错误类型'), |
| value: other.error_type, |
| }); |
| } |
| if (other?.error_code) { |
| expandDataLocal.push({ |
| key: t('错误代码'), |
| value: other.error_code, |
| }); |
| } |
| if (other?.status_code) { |
| expandDataLocal.push({ |
| key: t('状态码'), |
| value: other.status_code, |
| }); |
| } |
| if (other?.channel_name) { |
| expandDataLocal.push({ |
| key: t('错误渠道'), |
| value: `${other.channel_id} - ${other.channel_name}`, |
| }); |
| } |
| |
| if (logs[i].client_ip && logs[i].client_ip.trim() !== '') { |
| expandDataLocal.push({ |
| key: t('客户端IP'), |
| value: ( |
| <Tag |
| color='blue' |
| size='large' |
| onClick={(event) => { |
| copyText(event, logs[i].client_ip); |
| }} |
| style={{ |
| fontFamily: 'monospace', |
| cursor: 'pointer' |
| }} |
| > |
| {logs[i].client_ip} |
| </Tag> |
| ), |
| }); |
| } |
| expandDataLocal.push({ |
| key: t('错误详情'), |
| value: ( |
| <Paragraph |
| ellipsis={{ |
| rows: 6, |
| expandable: true, |
| collapsible: true, |
| collapseText: t('收起'), |
| expandText: t('展开'), |
| }} |
| style={{ maxWidth: '100%', whiteSpace: 'pre-wrap' }} |
| > |
| {logs[i].content} |
| </Paragraph> |
| ), |
| }); |
| } |
| |
| |
| if (logs[i].type === 2) { |
| let other = getLogOther(logs[i].other); |
| |
| |
| if (other?.system_prompt) { |
| expandDataLocal.push({ |
| key: t('系统提示'), |
| value: ( |
| <Paragraph |
| ellipsis={{ |
| rows: 3, |
| expandable: true, |
| collapsible: true, |
| collapseText: t('收起'), |
| expandText: t('展开'), |
| }} |
| style={{ maxWidth: '100%', whiteSpace: 'pre-wrap' }} |
| > |
| {other.system_prompt} |
| </Paragraph> |
| ), |
| }); |
| } |
| |
| |
| if (other?.context) { |
| let contextStr = ""; |
| |
| if (Array.isArray(other.context)) { |
| |
| if (other.context.length > 0) { |
| contextStr = other.context.map(msg => { |
| if (typeof msg === 'object' && msg !== null && msg.role && msg.content) { |
| |
| let roleDisplay = '未知'; |
| switch(msg.role) { |
| case 'user': |
| roleDisplay = '用户'; |
| break; |
| case 'assistant': |
| roleDisplay = '助手'; |
| break; |
| case 'system': |
| roleDisplay = '系统'; |
| break; |
| default: |
| roleDisplay = msg.role; |
| } |
| return `${roleDisplay}消息: ${msg.content}`; |
| } else { |
| |
| return JSON.stringify(msg, null, 2); |
| } |
| }).join('\n\n'); |
| } else { |
| contextStr = "空上下文"; |
| } |
| } else if (typeof other.context === 'string') { |
| contextStr = other.context; |
| } else { |
| contextStr = JSON.stringify(other.context, null, 2); |
| } |
| |
| expandDataLocal.push({ |
| key: t('上下文'), |
| value: ( |
| <Paragraph |
| ellipsis={{ |
| rows: 3, |
| expandable: true, |
| collapsible: true, |
| collapseText: t('收起'), |
| expandText: t('展开'), |
| }} |
| style={{ maxWidth: '100%', whiteSpace: 'pre-wrap' }} |
| > |
| {contextStr} |
| </Paragraph> |
| ), |
| }); |
| } |
| |
| |
| if (other?.input_content) { |
| |
| let inputContent = ""; |
| if (typeof other.input_content === 'object' && other.input_content !== null) { |
| if (other.input_content.role === 'user' && other.input_content.content) { |
| |
| inputContent = `用户消息: ${other.input_content.content}`; |
| } else if (other.input_content.content) { |
| |
| const role = other.input_content.role || '未知'; |
| inputContent = `${role}消息: ${other.input_content.content}`; |
| } else { |
| |
| inputContent = JSON.stringify(other.input_content, null, 2); |
| } |
| } else if (typeof other.input_content === 'string') { |
| inputContent = other.input_content; |
| } else { |
| inputContent = JSON.stringify(other.input_content, null, 2); |
| } |
| |
| expandDataLocal.push({ |
| key: t('输入'), |
| value: ( |
| <Paragraph |
| ellipsis={{ |
| rows: 4, |
| expandable: true, |
| collapsible: true, |
| collapseText: t('收起'), |
| expandText: t('展开'), |
| }} |
| style={{ maxWidth: '100%', whiteSpace: 'pre-wrap' }} |
| > |
| {inputContent} |
| </Paragraph> |
| ), |
| }); |
| } |
| |
| |
| if (other?.output_content) { |
| expandDataLocal.push({ |
| key: t('输出'), |
| value: ( |
| <Paragraph |
| ellipsis={{ |
| rows: 5, |
| expandable: true, |
| collapsible: true, |
| collapseText: t('收起'), |
| expandText: t('展开'), |
| }} |
| style={{ maxWidth: '100%', whiteSpace: 'pre-wrap' }} |
| > |
| {other.output_content} |
| </Paragraph> |
| ), |
| }); |
| } |
| |
| |
| if (logs[i].client_ip && logs[i].client_ip.trim() !== '') { |
| expandDataLocal.push({ |
| key: t('客户端IP'), |
| value: ( |
| <Tag |
| color='blue' |
| size='large' |
| onClick={(event) => { |
| copyText(event, logs[i].client_ip); |
| }} |
| style={{ |
| fontFamily: 'monospace', |
| cursor: 'pointer' |
| }} |
| > |
| {logs[i].client_ip} |
| </Tag> |
| ), |
| }); |
| } |
| } |
| |
| expandDatesLocal[logs[i].key] = expandDataLocal; |
| } |
|
|
| |
| setStat(prevStat => ({ |
| ...prevStat, |
| token_total: totalTokens, |
| token_input: totalInputTokens, |
| token_output: totalOutputTokens |
| })); |
| |
| setExpandData(expandDatesLocal); |
| setLogs(logs); |
| }; |
|
|
| const loadLogs = async (startIdx, pageSize, logType = 0) => { |
| setLoading(true); |
|
|
| let url = ''; |
| 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=${logType}&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=${logType}&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, logType).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(activePage, pageSize, logType); |
| }; |
|
|
| 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); |
| }); |
| handleEyeClick(); |
| }, []); |
|
|
| const expandRowRender = (record, index) => { |
| return <Descriptions data={expandData[record.key]} />; |
| }; |
|
|
| return ( |
| <> |
| {renderColumnSelector()} |
| <Layout> |
| <Header> |
| <Spin spinning={loadingStat}> |
| <Space> |
| <Tag |
| color='blue' |
| size='large' |
| style={{ |
| padding: 15, |
| borderRadius: '8px', |
| fontWeight: 500, |
| boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', |
| }} |
| > |
| {t('消耗额度')}: {renderQuota(stat.quota)} |
| </Tag> |
| <Tag |
| color='green' |
| size='large' |
| style={{ |
| padding: 15, |
| borderRadius: '8px', |
| fontWeight: 500, |
| boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', |
| }} |
| > |
| {t('总Token')}: {stat.token_total || 0} |
| </Tag> |
| <Tag |
| color='orange' |
| size='large' |
| style={{ |
| padding: 15, |
| borderRadius: '8px', |
| fontWeight: 500, |
| boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', |
| }} |
| > |
| {t('输入Token')}: {stat.token_input || 0} |
| </Tag> |
| <Tag |
| color='purple' |
| size='large' |
| style={{ |
| padding: 15, |
| borderRadius: '8px', |
| fontWeight: 500, |
| boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', |
| }} |
| > |
| {t('输出Token')}: {stat.token_output || 0} |
| </Tag> |
| <Tag |
| color='pink' |
| size='large' |
| style={{ |
| padding: 15, |
| borderRadius: '8px', |
| fontWeight: 500, |
| boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', |
| }} |
| > |
| RPM: {stat.rpm} |
| </Tag> |
| <Tag |
| color='white' |
| size='large' |
| style={{ |
| padding: 15, |
| border: 'none', |
| boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', |
| borderRadius: '8px', |
| fontWeight: 500, |
| }} |
| > |
| TPM: {stat.tpm} |
| </Tag> |
| </Space> |
| </Spin> |
| </Header> |
| <Form layout='horizontal' style={{ marginTop: 10 }}> |
| <> |
| <Form.Section> |
| <div style={{ marginBottom: 10 }}> |
| {styleState.isMobile ? ( |
| <div> |
| <Form.DatePicker |
| field='start_timestamp' |
| label={t('起始时间')} |
| style={{ width: 272 }} |
| initValue={start_timestamp} |
| type='dateTime' |
| onChange={(value) => { |
| console.log(value); |
| handleInputChange(value, 'start_timestamp'); |
| }} |
| /> |
| <Form.DatePicker |
| field='end_timestamp' |
| fluid |
| label={t('结束时间')} |
| style={{ width: 272 }} |
| initValue={end_timestamp} |
| type='dateTime' |
| onChange={(value) => |
| handleInputChange(value, 'end_timestamp') |
| } |
| /> |
| </div> |
| ) : ( |
| <Form.DatePicker |
| field='range_timestamp' |
| label={t('时间范围')} |
| initValue={[start_timestamp, end_timestamp]} |
| type='dateTimeRange' |
| name='range_timestamp' |
| onChange={(value) => { |
| if (Array.isArray(value) && value.length === 2) { |
| handleInputChange(value[0], 'start_timestamp'); |
| handleInputChange(value[1], 'end_timestamp'); |
| } |
| }} |
| /> |
| )} |
| </div> |
| </Form.Section> |
| <Form.Input |
| field='token_name' |
| label={t('令牌名称')} |
| value={token_name} |
| placeholder={t('可选值')} |
| name='token_name' |
| onChange={(value) => handleInputChange(value, 'token_name')} |
| /> |
| <Form.Input |
| field='model_name' |
| label={t('模型名称')} |
| value={model_name} |
| placeholder={t('可选值')} |
| name='model_name' |
| onChange={(value) => handleInputChange(value, 'model_name')} |
| /> |
| <Form.Input |
| field='group' |
| label={t('分组')} |
| value={group} |
| placeholder={t('可选值')} |
| name='group' |
| onChange={(value) => handleInputChange(value, 'group')} |
| /> |
| {isAdminUser && ( |
| <> |
| <Form.Input |
| field='channel' |
| label={t('渠道 ID')} |
| value={channel} |
| placeholder={t('可选值')} |
| name='channel' |
| onChange={(value) => handleInputChange(value, 'channel')} |
| /> |
| <Form.Input |
| field='username' |
| label={t('用户名称')} |
| value={username} |
| placeholder={t('可选值')} |
| name='username' |
| onChange={(value) => handleInputChange(value, 'username')} |
| /> |
| </> |
| )} |
| <Button |
| label={t('查询')} |
| type='primary' |
| htmlType='submit' |
| className='btn-margin-right' |
| onClick={refresh} |
| loading={loading} |
| style={{ marginTop: 24 }} |
| > |
| {t('查询')} |
| </Button> |
| <Button |
| theme='light' |
| type='primary' |
| icon={<IconRefresh />} |
| onClick={refresh} |
| loading={loading} |
| style={{ marginTop: 24, marginLeft: 8 }} |
| > |
| {t('刷新')} |
| </Button> |
| <Form.Section></Form.Section> |
| </> |
| </Form> |
| <div style={{ marginTop: 10 }}> |
| <Select |
| defaultValue='0' |
| style={{ width: 120 }} |
| onChange={(value) => { |
| setLogType(parseInt(value)); |
| loadLogs(0, pageSize, parseInt(value)); |
| }} |
| > |
| <Select.Option value='0'>{t('全部')}</Select.Option> |
| <Select.Option value='1'>{t('充值')}</Select.Option> |
| <Select.Option value='2'>{t('消费')}</Select.Option> |
| <Select.Option value='3'>{t('管理')}</Select.Option> |
| <Select.Option value='4'>{t('系统')}</Select.Option> |
| <Select.Option value='5'>{t('签到')}</Select.Option> {/* 添加签到选项 */} |
| <Select.Option value='6'>{t('错误')}</Select.Option> |
| </Select> |
| <Button |
| theme='light' |
| type='tertiary' |
| icon={<IconSetting />} |
| onClick={() => setShowColumnSelector(true)} |
| style={{ marginLeft: 8 }} |
| > |
| {t('列设置')} |
| </Button> |
| </div> |
| <Table |
| style={{ marginTop: 5 }} |
| columns={getVisibleColumns()} |
| expandedRowRender={expandRowRender} |
| expandRowByClick={true} |
| dataSource={logs} |
| rowKey='key' |
| pagination={{ |
| formatPageText: (page) => |
| t('第 {{start}} - {{end}} 条,共 {{total}} 条', { |
| start: page.currentStart, |
| end: page.currentEnd, |
| total: logCount, |
| }), |
| currentPage: activePage, |
| pageSize: pageSize, |
| total: logCount, |
| pageSizeOpts: [10, 20, 50, 100], |
| showSizeChanger: true, |
| onPageSizeChange: (size) => { |
| handlePageSizeChange(size); |
| }, |
| onPageChange: handlePageChange, |
| }} |
| /> |
| </Layout> |
| </> |
| ); |
| }; |
|
|
| export default LogsTable; |
|
|