- Add speed test tag with gauge icon for each API route - Integrate tcptest.cn service for API endpoint performance testing - Implement handleSpeedTest callback to open speed test in new tab - Add Tag component import from @douyinfe/semi-ui - Use Gauge icon with white circular tag styling - Position speed test tag before API route for better visibility - URL encoding handles special characters for proper test URL generation - Remove unused IconTestScoreStroked import and clean up comments The speed test feature allows users to quickly test API endpoint performance by clicking a small circular tag that opens the tcptest.cn speed testing service with the encoded API URL.
1048 lines
31 KiB
JavaScript
1048 lines
31 KiB
JavaScript
import React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Wallet, Activity, Zap, Gauge, PieChart } from 'lucide-react';
|
||
|
||
import {
|
||
Card,
|
||
Form,
|
||
Spin,
|
||
IconButton,
|
||
Modal,
|
||
Avatar,
|
||
Tabs,
|
||
TabPane,
|
||
Empty,
|
||
Tag
|
||
} from '@douyinfe/semi-ui';
|
||
import {
|
||
IconRefresh,
|
||
IconSearch,
|
||
IconMoneyExchangeStroked,
|
||
IconHistogram,
|
||
IconRotate,
|
||
IconCoinMoneyStroked,
|
||
IconTextStroked,
|
||
IconPulse,
|
||
IconStopwatchStroked,
|
||
IconTypograph,
|
||
IconPieChart2Stroked
|
||
} from '@douyinfe/semi-icons';
|
||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||
import { VChart } from '@visactor/react-vchart';
|
||
import {
|
||
API,
|
||
isAdmin,
|
||
isMobile,
|
||
showError,
|
||
timestamp2string,
|
||
timestamp2string1,
|
||
getQuotaWithUnit,
|
||
modelColorMap,
|
||
renderNumber,
|
||
renderQuota,
|
||
modelToColor,
|
||
copy,
|
||
showSuccess
|
||
} from '../../helpers';
|
||
import { UserContext } from '../../context/User/index.js';
|
||
import { StatusContext } from '../../context/Status/index.js';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
const Detail = (props) => {
|
||
// ========== Hooks - Context ==========
|
||
const [userState, userDispatch] = useContext(UserContext);
|
||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||
|
||
// ========== Hooks - Navigation & Translation ==========
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
|
||
// ========== Hooks - Refs ==========
|
||
const formRef = useRef();
|
||
const initialized = useRef(false);
|
||
const apiScrollRef = useRef(null);
|
||
|
||
// ========== Constants & Shared Configurations ==========
|
||
const CHART_CONFIG = { mode: 'desktop-browser' };
|
||
|
||
const CARD_PROPS = {
|
||
shadows: 'always',
|
||
bordered: false,
|
||
headerLine: true
|
||
};
|
||
|
||
const FORM_FIELD_PROPS = {
|
||
className: "w-full mb-2 !rounded-lg",
|
||
size: 'large'
|
||
};
|
||
|
||
const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
|
||
const FLEX_CENTER_GAP2 = "flex items-center gap-2";
|
||
|
||
// ========== Constants ==========
|
||
let now = new Date();
|
||
const isAdminUser = isAdmin();
|
||
|
||
// ========== Helper Functions ==========
|
||
const getDefaultTime = useCallback(() => {
|
||
return localStorage.getItem('data_export_default_time') || 'hour';
|
||
}, []);
|
||
|
||
const getTimeInterval = useCallback((timeType, isSeconds = false) => {
|
||
const intervals = {
|
||
hour: isSeconds ? 3600 : 60,
|
||
day: isSeconds ? 86400 : 1440,
|
||
week: isSeconds ? 604800 : 10080
|
||
};
|
||
return intervals[timeType] || intervals.hour;
|
||
}, []);
|
||
|
||
const getInitialTimestamp = useCallback(() => {
|
||
const defaultTime = getDefaultTime();
|
||
const now = new Date().getTime() / 1000;
|
||
|
||
switch (defaultTime) {
|
||
case 'hour':
|
||
return timestamp2string(now - 86400);
|
||
case 'week':
|
||
return timestamp2string(now - 86400 * 30);
|
||
default:
|
||
return timestamp2string(now - 86400 * 7);
|
||
}
|
||
}, [getDefaultTime]);
|
||
|
||
const updateMapValue = useCallback((map, key, value) => {
|
||
if (!map.has(key)) {
|
||
map.set(key, 0);
|
||
}
|
||
map.set(key, map.get(key) + value);
|
||
}, []);
|
||
|
||
const initializeMaps = useCallback((key, ...maps) => {
|
||
maps.forEach(map => {
|
||
if (!map.has(key)) {
|
||
map.set(key, 0);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
const updateChartSpec = useCallback((setterFunc, newData, subtitle, newColors, dataId) => {
|
||
setterFunc(prev => ({
|
||
...prev,
|
||
data: [{ id: dataId, values: newData }],
|
||
title: {
|
||
...prev.title,
|
||
subtext: subtitle,
|
||
},
|
||
color: {
|
||
specified: newColors,
|
||
},
|
||
}));
|
||
}, []);
|
||
|
||
const createSectionTitle = useCallback((Icon, text) => (
|
||
<div className={FLEX_CENTER_GAP2}>
|
||
<Icon size={16} />
|
||
{text}
|
||
</div>
|
||
), []);
|
||
|
||
const createFormField = useCallback((Component, props) => (
|
||
<Component {...FORM_FIELD_PROPS} {...props} />
|
||
), []);
|
||
|
||
// ========== Time Options ==========
|
||
const timeOptions = useMemo(() => [
|
||
{ label: t('小时'), value: 'hour' },
|
||
{ label: t('天'), value: 'day' },
|
||
{ label: t('周'), value: 'week' },
|
||
], [t]);
|
||
|
||
// ========== Hooks - State ==========
|
||
const [inputs, setInputs] = useState({
|
||
username: '',
|
||
token_name: '',
|
||
model_name: '',
|
||
start_timestamp: getInitialTimestamp(),
|
||
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
||
channel: '',
|
||
data_export_default_time: '',
|
||
});
|
||
|
||
const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime());
|
||
|
||
const [loading, setLoading] = useState(false);
|
||
const [quotaData, setQuotaData] = useState([]);
|
||
const [consumeQuota, setConsumeQuota] = useState(0);
|
||
const [consumeTokens, setConsumeTokens] = useState(0);
|
||
const [times, setTimes] = useState(0);
|
||
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
|
||
const [lineData, setLineData] = useState([]);
|
||
const [apiInfoData, setApiInfoData] = useState([]);
|
||
const [modelColors, setModelColors] = useState({});
|
||
const [activeChartTab, setActiveChartTab] = useState('1');
|
||
const [showApiScrollHint, setShowApiScrollHint] = useState(false);
|
||
const [searchModalVisible, setSearchModalVisible] = useState(false);
|
||
|
||
const [trendData, setTrendData] = useState({
|
||
balance: [],
|
||
usedQuota: [],
|
||
requestCount: [],
|
||
times: [],
|
||
consumeQuota: [],
|
||
tokens: [],
|
||
rpm: [],
|
||
tpm: []
|
||
});
|
||
|
||
// ========== Props Destructuring ==========
|
||
const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
|
||
|
||
// ========== Chart Specs State ==========
|
||
const [spec_pie, setSpecPie] = useState({
|
||
type: 'pie',
|
||
data: [
|
||
{
|
||
id: 'id0',
|
||
values: pieData,
|
||
},
|
||
],
|
||
outerRadius: 0.8,
|
||
innerRadius: 0.5,
|
||
padAngle: 0.6,
|
||
valueField: 'value',
|
||
categoryField: 'type',
|
||
pie: {
|
||
style: {
|
||
cornerRadius: 10,
|
||
},
|
||
state: {
|
||
hover: {
|
||
outerRadius: 0.85,
|
||
stroke: '#000',
|
||
lineWidth: 1,
|
||
},
|
||
selected: {
|
||
outerRadius: 0.85,
|
||
stroke: '#000',
|
||
lineWidth: 1,
|
||
},
|
||
},
|
||
},
|
||
title: {
|
||
visible: true,
|
||
text: t('模型调用次数占比'),
|
||
subtext: `${t('总计')}:${renderNumber(times)}`,
|
||
},
|
||
legends: {
|
||
visible: true,
|
||
orient: 'left',
|
||
},
|
||
label: {
|
||
visible: true,
|
||
},
|
||
tooltip: {
|
||
mark: {
|
||
content: [
|
||
{
|
||
key: (datum) => datum['type'],
|
||
value: (datum) => renderNumber(datum['value']),
|
||
},
|
||
],
|
||
},
|
||
},
|
||
color: {
|
||
specified: modelColorMap,
|
||
},
|
||
});
|
||
|
||
const [spec_line, setSpecLine] = useState({
|
||
type: 'bar',
|
||
data: [
|
||
{
|
||
id: 'barData',
|
||
values: lineData,
|
||
},
|
||
],
|
||
xField: 'Time',
|
||
yField: 'Usage',
|
||
seriesField: 'Model',
|
||
stack: true,
|
||
legends: {
|
||
visible: true,
|
||
selectMode: 'single',
|
||
},
|
||
title: {
|
||
visible: true,
|
||
text: t('模型消耗分布'),
|
||
subtext: `${t('总计')}:${renderQuota(consumeQuota, 2)}`,
|
||
},
|
||
bar: {
|
||
state: {
|
||
hover: {
|
||
stroke: '#000',
|
||
lineWidth: 1,
|
||
},
|
||
},
|
||
},
|
||
tooltip: {
|
||
mark: {
|
||
content: [
|
||
{
|
||
key: (datum) => datum['Model'],
|
||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||
},
|
||
],
|
||
},
|
||
dimension: {
|
||
content: [
|
||
{
|
||
key: (datum) => datum['Model'],
|
||
value: (datum) => datum['rawQuota'] || 0,
|
||
},
|
||
],
|
||
updateContent: (array) => {
|
||
array.sort((a, b) => b.value - a.value);
|
||
let sum = 0;
|
||
for (let i = 0; i < array.length; i++) {
|
||
if (array[i].key == '其他') {
|
||
continue;
|
||
}
|
||
let value = parseFloat(array[i].value);
|
||
if (isNaN(value)) {
|
||
value = 0;
|
||
}
|
||
if (array[i].datum && array[i].datum.TimeSum) {
|
||
sum = array[i].datum.TimeSum;
|
||
}
|
||
array[i].value = renderQuota(value, 4);
|
||
}
|
||
array.unshift({
|
||
key: t('总计'),
|
||
value: renderQuota(sum, 4),
|
||
});
|
||
return array;
|
||
},
|
||
},
|
||
},
|
||
color: {
|
||
specified: modelColorMap,
|
||
},
|
||
});
|
||
|
||
// ========== Hooks - Memoized Values ==========
|
||
const performanceMetrics = useMemo(() => {
|
||
const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
|
||
const avgRPM = (times / timeDiff).toFixed(3);
|
||
const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3);
|
||
|
||
return { avgRPM, avgTPM, timeDiff };
|
||
}, [times, consumeTokens, end_timestamp, start_timestamp]);
|
||
|
||
const getGreeting = useMemo(() => {
|
||
const hours = new Date().getHours();
|
||
let greeting = '';
|
||
|
||
if (hours >= 5 && hours < 12) {
|
||
greeting = t('早上好');
|
||
} else if (hours >= 12 && hours < 14) {
|
||
greeting = t('中午好');
|
||
} else if (hours >= 14 && hours < 18) {
|
||
greeting = t('下午好');
|
||
} else {
|
||
greeting = t('晚上好');
|
||
}
|
||
|
||
const username = userState?.user?.username || '';
|
||
return `👋${greeting},${username}`;
|
||
}, [t, userState?.user?.username]);
|
||
|
||
// ========== Hooks - Callbacks ==========
|
||
const getTrendSpec = useCallback((data, color) => ({
|
||
type: 'line',
|
||
data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
|
||
xField: 'x',
|
||
yField: 'y',
|
||
height: 40,
|
||
width: 100,
|
||
axes: [
|
||
{
|
||
orient: 'bottom',
|
||
visible: false
|
||
},
|
||
{
|
||
orient: 'left',
|
||
visible: false
|
||
}
|
||
],
|
||
padding: 0,
|
||
autoFit: false,
|
||
legends: { visible: false },
|
||
tooltip: { visible: false },
|
||
crosshair: { visible: false },
|
||
line: {
|
||
style: {
|
||
stroke: color,
|
||
lineWidth: 2
|
||
}
|
||
},
|
||
point: {
|
||
visible: false
|
||
},
|
||
background: {
|
||
fill: 'transparent'
|
||
}
|
||
}), []);
|
||
|
||
const groupedStatsData = useMemo(() => [
|
||
{
|
||
title: createSectionTitle(Wallet, t('账户数据')),
|
||
color: 'bg-blue-50',
|
||
items: [
|
||
{
|
||
title: t('当前余额'),
|
||
value: renderQuota(userState?.user?.quota),
|
||
icon: <IconMoneyExchangeStroked size="large" />,
|
||
avatarColor: 'blue',
|
||
onClick: () => navigate('/console/topup'),
|
||
trendData: [],
|
||
trendColor: '#3b82f6'
|
||
},
|
||
{
|
||
title: t('历史消耗'),
|
||
value: renderQuota(userState?.user?.used_quota),
|
||
icon: <IconHistogram size="large" />,
|
||
avatarColor: 'purple',
|
||
trendData: [],
|
||
trendColor: '#8b5cf6'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: createSectionTitle(Activity, t('使用统计')),
|
||
color: 'bg-green-50',
|
||
items: [
|
||
{
|
||
title: t('请求次数'),
|
||
value: userState.user?.request_count,
|
||
icon: <IconRotate size="large" />,
|
||
avatarColor: 'green',
|
||
trendData: [],
|
||
trendColor: '#10b981'
|
||
},
|
||
{
|
||
title: t('统计次数'),
|
||
value: times,
|
||
icon: <IconPulse size="large" />,
|
||
avatarColor: 'cyan',
|
||
trendData: trendData.times,
|
||
trendColor: '#06b6d4'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: createSectionTitle(Zap, t('资源消耗')),
|
||
color: 'bg-yellow-50',
|
||
items: [
|
||
{
|
||
title: t('统计额度'),
|
||
value: renderQuota(consumeQuota),
|
||
icon: <IconCoinMoneyStroked size="large" />,
|
||
avatarColor: 'yellow',
|
||
trendData: trendData.consumeQuota,
|
||
trendColor: '#f59e0b'
|
||
},
|
||
{
|
||
title: t('统计Tokens'),
|
||
value: isNaN(consumeTokens) ? 0 : consumeTokens,
|
||
icon: <IconTextStroked size="large" />,
|
||
avatarColor: 'pink',
|
||
trendData: trendData.tokens,
|
||
trendColor: '#ec4899'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
title: createSectionTitle(Gauge, t('性能指标')),
|
||
color: 'bg-indigo-50',
|
||
items: [
|
||
{
|
||
title: t('平均RPM'),
|
||
value: performanceMetrics.avgRPM,
|
||
icon: <IconStopwatchStroked size="large" />,
|
||
avatarColor: 'indigo',
|
||
trendData: trendData.rpm,
|
||
trendColor: '#6366f1'
|
||
},
|
||
{
|
||
title: t('平均TPM'),
|
||
value: performanceMetrics.avgTPM,
|
||
icon: <IconTypograph size="large" />,
|
||
avatarColor: 'orange',
|
||
trendData: trendData.tpm,
|
||
trendColor: '#f97316'
|
||
}
|
||
]
|
||
}
|
||
], [
|
||
createSectionTitle, t, userState?.user?.quota, userState?.user?.used_quota, userState?.user?.request_count,
|
||
times, consumeQuota, consumeTokens, trendData, performanceMetrics, navigate
|
||
]);
|
||
|
||
const handleCopyUrl = useCallback(async (url) => {
|
||
if (await copy(url)) {
|
||
showSuccess(t('复制成功'));
|
||
}
|
||
}, [t]);
|
||
|
||
const handleSpeedTest = useCallback((apiUrl) => {
|
||
const encodedUrl = encodeURIComponent(apiUrl);
|
||
const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`;
|
||
window.open(speedTestUrl, '_blank');
|
||
}, []);
|
||
|
||
const handleInputChange = useCallback((value, name) => {
|
||
if (name === 'data_export_default_time') {
|
||
setDataExportDefaultTime(value);
|
||
return;
|
||
}
|
||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||
}, []);
|
||
|
||
const loadQuotaData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
let url = '';
|
||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||
if (isAdminUser) {
|
||
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||
} else {
|
||
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||
}
|
||
const res = await API.get(url);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
setQuotaData(data);
|
||
if (data.length === 0) {
|
||
data.push({
|
||
count: 0,
|
||
model_name: '无数据',
|
||
quota: 0,
|
||
created_at: now.getTime() / 1000,
|
||
});
|
||
}
|
||
data.sort((a, b) => a.created_at - b.created_at);
|
||
updateChartData(data);
|
||
} else {
|
||
showError(message);
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
|
||
|
||
const refresh = useCallback(async () => {
|
||
await loadQuotaData();
|
||
}, [loadQuotaData]);
|
||
|
||
const handleSearchConfirm = useCallback(() => {
|
||
refresh();
|
||
setSearchModalVisible(false);
|
||
}, [refresh]);
|
||
|
||
const initChart = useCallback(async () => {
|
||
await loadQuotaData();
|
||
}, [loadQuotaData]);
|
||
|
||
const showSearchModal = useCallback(() => {
|
||
setSearchModalVisible(true);
|
||
}, []);
|
||
|
||
const handleCloseModal = useCallback(() => {
|
||
setSearchModalVisible(false);
|
||
}, []);
|
||
|
||
// ========== Regular Functions ==========
|
||
const checkApiScrollable = () => {
|
||
if (apiScrollRef.current) {
|
||
const element = apiScrollRef.current;
|
||
const isScrollable = element.scrollHeight > element.clientHeight;
|
||
const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5;
|
||
setShowApiScrollHint(isScrollable && !isAtBottom);
|
||
}
|
||
};
|
||
|
||
const handleApiScroll = () => {
|
||
checkApiScrollable();
|
||
};
|
||
|
||
const getUserData = async () => {
|
||
let res = await API.get(`/api/user/self`);
|
||
const { success, message, data } = res.data;
|
||
if (success) {
|
||
userDispatch({ type: 'login', payload: data });
|
||
} else {
|
||
showError(message);
|
||
}
|
||
};
|
||
|
||
// ========== Data Processing Functions ==========
|
||
const processRawData = useCallback((data) => {
|
||
const result = {
|
||
totalQuota: 0,
|
||
totalTimes: 0,
|
||
totalTokens: 0,
|
||
uniqueModels: new Set(),
|
||
timePoints: [],
|
||
timeQuotaMap: new Map(),
|
||
timeTokensMap: new Map(),
|
||
timeCountMap: new Map()
|
||
};
|
||
|
||
data.forEach((item) => {
|
||
result.uniqueModels.add(item.model_name);
|
||
result.totalTokens += item.token_used;
|
||
result.totalQuota += item.quota;
|
||
result.totalTimes += item.count;
|
||
|
||
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
|
||
if (!result.timePoints.includes(timeKey)) {
|
||
result.timePoints.push(timeKey);
|
||
}
|
||
|
||
initializeMaps(timeKey, result.timeQuotaMap, result.timeTokensMap, result.timeCountMap);
|
||
updateMapValue(result.timeQuotaMap, timeKey, item.quota);
|
||
updateMapValue(result.timeTokensMap, timeKey, item.token_used);
|
||
updateMapValue(result.timeCountMap, timeKey, item.count);
|
||
});
|
||
|
||
result.timePoints.sort();
|
||
return result;
|
||
}, [dataExportDefaultTime, initializeMaps, updateMapValue]);
|
||
|
||
const calculateTrendData = useCallback((timePoints, timeQuotaMap, timeTokensMap, timeCountMap) => {
|
||
const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0);
|
||
const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0);
|
||
const countTrend = timePoints.map(time => timeCountMap.get(time) || 0);
|
||
|
||
const rpmTrend = [];
|
||
const tpmTrend = [];
|
||
|
||
if (timePoints.length >= 2) {
|
||
const interval = getTimeInterval(dataExportDefaultTime);
|
||
|
||
for (let i = 0; i < timePoints.length; i++) {
|
||
rpmTrend.push(timeCountMap.get(timePoints[i]) / interval);
|
||
tpmTrend.push(timeTokensMap.get(timePoints[i]) / interval);
|
||
}
|
||
}
|
||
|
||
return {
|
||
balance: [],
|
||
usedQuota: [],
|
||
requestCount: [],
|
||
times: countTrend,
|
||
consumeQuota: quotaTrend,
|
||
tokens: tokensTrend,
|
||
rpm: rpmTrend,
|
||
tpm: tpmTrend
|
||
};
|
||
}, [dataExportDefaultTime, getTimeInterval]);
|
||
|
||
const generateModelColors = useCallback((uniqueModels) => {
|
||
const newModelColors = {};
|
||
Array.from(uniqueModels).forEach((modelName) => {
|
||
newModelColors[modelName] =
|
||
modelColorMap[modelName] ||
|
||
modelColors[modelName] ||
|
||
modelToColor(modelName);
|
||
});
|
||
return newModelColors;
|
||
}, [modelColors]);
|
||
|
||
const aggregateDataByTimeAndModel = useCallback((data) => {
|
||
const aggregatedData = new Map();
|
||
|
||
data.forEach((item) => {
|
||
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
|
||
const modelKey = item.model_name;
|
||
const key = `${timeKey}-${modelKey}`;
|
||
|
||
if (!aggregatedData.has(key)) {
|
||
aggregatedData.set(key, {
|
||
time: timeKey,
|
||
model: modelKey,
|
||
quota: 0,
|
||
count: 0,
|
||
});
|
||
}
|
||
|
||
const existing = aggregatedData.get(key);
|
||
existing.quota += item.quota;
|
||
existing.count += item.count;
|
||
});
|
||
|
||
return aggregatedData;
|
||
}, [dataExportDefaultTime]);
|
||
|
||
const generateChartTimePoints = useCallback((aggregatedData, data) => {
|
||
let chartTimePoints = Array.from(
|
||
new Set([...aggregatedData.values()].map((d) => d.time)),
|
||
);
|
||
|
||
if (chartTimePoints.length < 7) {
|
||
const lastTime = Math.max(...data.map((item) => item.created_at));
|
||
const interval = getTimeInterval(dataExportDefaultTime, true);
|
||
|
||
chartTimePoints = Array.from({ length: 7 }, (_, i) =>
|
||
timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
|
||
);
|
||
}
|
||
|
||
return chartTimePoints;
|
||
}, [dataExportDefaultTime, getTimeInterval]);
|
||
|
||
const updateChartData = useCallback((data) => {
|
||
const processedData = processRawData(data);
|
||
const { totalQuota, totalTimes, totalTokens, uniqueModels, timePoints, timeQuotaMap, timeTokensMap, timeCountMap } = processedData;
|
||
|
||
const trendDataResult = calculateTrendData(timePoints, timeQuotaMap, timeTokensMap, timeCountMap);
|
||
setTrendData(trendDataResult);
|
||
|
||
const newModelColors = generateModelColors(uniqueModels);
|
||
setModelColors(newModelColors);
|
||
|
||
const aggregatedData = aggregateDataByTimeAndModel(data);
|
||
|
||
const modelTotals = new Map();
|
||
for (let [_, value] of aggregatedData) {
|
||
updateMapValue(modelTotals, value.model, value.count);
|
||
}
|
||
|
||
const newPieData = Array.from(modelTotals).map(([model, count]) => ({
|
||
type: model,
|
||
value: count,
|
||
})).sort((a, b) => b.value - a.value);
|
||
|
||
const chartTimePoints = generateChartTimePoints(aggregatedData, data);
|
||
let newLineData = [];
|
||
|
||
chartTimePoints.forEach((time) => {
|
||
let timeData = Array.from(uniqueModels).map((model) => {
|
||
const key = `${time}-${model}`;
|
||
const aggregated = aggregatedData.get(key);
|
||
return {
|
||
Time: time,
|
||
Model: model,
|
||
rawQuota: aggregated?.quota || 0,
|
||
Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0,
|
||
};
|
||
});
|
||
|
||
const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
|
||
timeData.sort((a, b) => b.rawQuota - a.rawQuota);
|
||
timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }));
|
||
newLineData.push(...timeData);
|
||
});
|
||
|
||
newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||
|
||
updateChartSpec(
|
||
setSpecPie,
|
||
newPieData,
|
||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||
newModelColors,
|
||
'id0'
|
||
);
|
||
|
||
updateChartSpec(
|
||
setSpecLine,
|
||
newLineData,
|
||
`${t('总计')}:${renderQuota(totalQuota, 2)}`,
|
||
newModelColors,
|
||
'barData'
|
||
);
|
||
|
||
setPieData(newPieData);
|
||
setLineData(newLineData);
|
||
setConsumeQuota(totalQuota);
|
||
setTimes(totalTimes);
|
||
setConsumeTokens(totalTokens);
|
||
}, [
|
||
processRawData, calculateTrendData, generateModelColors, aggregateDataByTimeAndModel,
|
||
generateChartTimePoints, updateChartSpec, updateMapValue, t
|
||
]);
|
||
|
||
// ========== Hooks - Effects ==========
|
||
useEffect(() => {
|
||
getUserData();
|
||
if (!initialized.current) {
|
||
initVChartSemiTheme({
|
||
isWatchingThemeSwitch: true,
|
||
});
|
||
initialized.current = true;
|
||
initChart();
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (statusState?.status?.api_info) {
|
||
setApiInfoData(statusState.status.api_info);
|
||
}
|
||
}, [statusState?.status?.api_info]);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
checkApiScrollable();
|
||
}, 100);
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
return (
|
||
<div className="bg-gray-50 h-full">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
|
||
<div className="flex gap-3">
|
||
<IconButton
|
||
icon={<IconSearch />}
|
||
onClick={showSearchModal}
|
||
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
|
||
/>
|
||
<IconButton
|
||
icon={<IconRefresh />}
|
||
onClick={refresh}
|
||
loading={loading}
|
||
className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 搜索条件Modal */}
|
||
<Modal
|
||
title={t('搜索条件')}
|
||
visible={searchModalVisible}
|
||
onOk={handleSearchConfirm}
|
||
onCancel={handleCloseModal}
|
||
closeOnEsc={true}
|
||
size={isMobile() ? 'full-width' : 'small'}
|
||
centered
|
||
>
|
||
<Form ref={formRef} layout='vertical' className="w-full">
|
||
{createFormField(Form.DatePicker, {
|
||
field: 'start_timestamp',
|
||
label: t('起始时间'),
|
||
initValue: start_timestamp,
|
||
value: start_timestamp,
|
||
type: 'dateTime',
|
||
name: 'start_timestamp',
|
||
onChange: (value) => handleInputChange(value, 'start_timestamp')
|
||
})}
|
||
|
||
{createFormField(Form.DatePicker, {
|
||
field: 'end_timestamp',
|
||
label: t('结束时间'),
|
||
initValue: end_timestamp,
|
||
value: end_timestamp,
|
||
type: 'dateTime',
|
||
name: 'end_timestamp',
|
||
onChange: (value) => handleInputChange(value, 'end_timestamp')
|
||
})}
|
||
|
||
{createFormField(Form.Select, {
|
||
field: 'data_export_default_time',
|
||
label: t('时间粒度'),
|
||
initValue: dataExportDefaultTime,
|
||
placeholder: t('时间粒度'),
|
||
name: 'data_export_default_time',
|
||
optionList: timeOptions,
|
||
onChange: (value) => handleInputChange(value, 'data_export_default_time')
|
||
})}
|
||
|
||
{isAdminUser && createFormField(Form.Input, {
|
||
field: 'username',
|
||
label: t('用户名称'),
|
||
value: username,
|
||
placeholder: t('可选值'),
|
||
name: 'username',
|
||
onChange: (value) => handleInputChange(value, 'username')
|
||
})}
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Spin spinning={loading}>
|
||
<div className="mb-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{groupedStatsData.map((group, idx) => (
|
||
<Card
|
||
key={idx}
|
||
{...CARD_PROPS}
|
||
className={`${group.color} border-0 !rounded-2xl w-full`}
|
||
title={group.title}
|
||
>
|
||
<div className="space-y-4">
|
||
{group.items.map((item, itemIdx) => (
|
||
<div
|
||
key={itemIdx}
|
||
className="flex items-center justify-between cursor-pointer"
|
||
onClick={item.onClick}
|
||
>
|
||
<div className="flex items-center">
|
||
<Avatar
|
||
className="mr-3"
|
||
size="small"
|
||
color={item.avatarColor}
|
||
>
|
||
{item.icon}
|
||
</Avatar>
|
||
<div>
|
||
<div className="text-xs text-gray-500">{item.title}</div>
|
||
<div className="text-lg font-semibold">{item.value}</div>
|
||
</div>
|
||
</div>
|
||
{item.trendData && item.trendData.length > 0 && (
|
||
<div className="w-24 h-10">
|
||
<VChart
|
||
spec={getTrendSpec(item.trendData, item.trendColor)}
|
||
option={CHART_CONFIG}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<div className={`grid grid-cols-1 gap-4 ${!statusState?.status?.self_use_mode_enabled ? 'lg:grid-cols-4' : ''}`}>
|
||
<Card
|
||
{...CARD_PROPS}
|
||
className={`shadow-sm !rounded-2xl ${!statusState?.status?.self_use_mode_enabled ? 'lg:col-span-3' : ''}`}
|
||
title={
|
||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
|
||
<div className={FLEX_CENTER_GAP2}>
|
||
<PieChart size={16} />
|
||
{t('模型数据分析')}
|
||
</div>
|
||
<Tabs
|
||
type="button"
|
||
activeKey={activeChartTab}
|
||
onChange={setActiveChartTab}
|
||
>
|
||
<TabPane tab={
|
||
<span>
|
||
<IconHistogram />
|
||
{t('消耗分布')}
|
||
</span>
|
||
} itemKey="1" />
|
||
<TabPane tab={
|
||
<span>
|
||
<IconPieChart2Stroked />
|
||
{t('调用次数分布')}
|
||
</span>
|
||
} itemKey="2" />
|
||
</Tabs>
|
||
</div>
|
||
}
|
||
>
|
||
<div style={{ height: 400 }}>
|
||
{activeChartTab === '1' ? (
|
||
<VChart
|
||
spec={spec_line}
|
||
option={CHART_CONFIG}
|
||
/>
|
||
) : (
|
||
<VChart
|
||
spec={spec_pie}
|
||
option={CHART_CONFIG}
|
||
/>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
{!statusState?.status?.self_use_mode_enabled && (
|
||
<Card
|
||
{...CARD_PROPS}
|
||
className="bg-gray-50 border-0 !rounded-2xl"
|
||
title={
|
||
<div className={FLEX_CENTER_GAP2}>
|
||
<IconSearch size={16} />
|
||
{t('API信息')}
|
||
</div>
|
||
}
|
||
>
|
||
<div className="api-info-container">
|
||
<div
|
||
ref={apiScrollRef}
|
||
className="space-y-3 max-h-96 overflow-y-auto api-info-scroll"
|
||
onScroll={handleApiScroll}
|
||
>
|
||
{apiInfoData.length > 0 ? (
|
||
apiInfoData.map((api) => (
|
||
<div key={api.id} className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
|
||
<div className="flex-shrink-0 mr-3">
|
||
<Avatar
|
||
size="extra-extra-small"
|
||
color={api.color}
|
||
>
|
||
{api.route.substring(0, 2)}
|
||
</Avatar>
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="text-sm font-medium text-gray-900 mb-1 !font-bold flex items-center gap-2">
|
||
<Tag
|
||
prefixIcon={<Gauge size={12} />}
|
||
size="small"
|
||
color="white"
|
||
shape='circle'
|
||
onClick={() => handleSpeedTest(api.url)}
|
||
className="cursor-pointer hover:opacity-80 text-xs"
|
||
>
|
||
{t('测速')}
|
||
</Tag>
|
||
{api.route}
|
||
</div>
|
||
<div
|
||
className="text-xs !text-semi-color-primary font-mono break-all cursor-pointer hover:underline mb-1"
|
||
onClick={() => handleCopyUrl(api.url)}
|
||
>
|
||
{api.url}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
{api.description}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="flex justify-center items-center py-8">
|
||
<Empty
|
||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
||
title={t('暂无API信息配置')}
|
||
description={t('请联系管理员在系统设置中配置API信息')}
|
||
style={{ padding: '12px' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="api-info-fade-indicator"
|
||
style={{ opacity: showApiScrollHint ? 1 : 0 }}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Spin>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Detail;
|