666 lines
18 KiB
JavaScript
Raw Normal View History

import React, { useContext, useEffect, useRef, useState } from 'react';
2024-03-23 20:22:00 +08:00
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import { useNavigate } from 'react-router-dom';
2024-03-23 20:22:00 +08:00
2025-04-04 12:00:38 +08:00
import {
Card,
Form,
Spin,
2025-05-20 18:01:38 +08:00
IconButton,
Modal,
Avatar,
2025-04-04 12:00:38 +08:00
} from '@douyinfe/semi-ui';
import {
IconRefresh,
IconSearch,
IconMoneyExchangeStroked,
IconHistogram,
IconRotate,
IconCoinMoneyStroked,
IconTextStroked,
IconPulse,
IconStopwatchStroked,
IconTypograph,
} from '@douyinfe/semi-icons';
2025-04-04 12:00:38 +08:00
import { VChart } from '@visactor/react-vchart';
2024-01-10 17:49:55 +08:00
import {
2024-03-23 21:24:39 +08:00
API,
isAdmin,
isMobile,
2024-03-23 21:24:39 +08:00
showError,
timestamp2string,
timestamp2string1,
getQuotaWithUnit,
modelColorMap,
renderNumber,
renderQuota,
modelToColor
} from '../../helpers';
import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next';
2024-01-07 18:31:14 +08:00
const Detail = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
2024-03-23 21:24:39 +08:00
const formRef = useRef();
let now = new Date();
const [userState, userDispatch] = useContext(UserContext);
2024-03-23 21:24:39 +08:00
const [inputs, setInputs] = useState({
username: '',
token_name: '',
model_name: '',
start_timestamp:
localStorage.getItem('data_export_default_time') === 'hour'
? timestamp2string(now.getTime() / 1000 - 86400)
: localStorage.getItem('data_export_default_time') === 'week'
? timestamp2string(now.getTime() / 1000 - 86400 * 30)
: timestamp2string(now.getTime() / 1000 - 86400 * 7),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
channel: '',
data_export_default_time: '',
});
const { username, model_name, start_timestamp, end_timestamp, channel } =
inputs;
const isAdminUser = isAdmin();
const initialized = useRef(false);
const [loading, setLoading] = useState(false);
const [quotaData, setQuotaData] = useState([]);
const [consumeQuota, setConsumeQuota] = useState(0);
const [consumeTokens, setConsumeTokens] = useState(0);
2024-03-23 21:24:39 +08:00
const [times, setTimes] = useState(0);
const [dataExportDefaultTime, setDataExportDefaultTime] = useState(
localStorage.getItem('data_export_default_time') || 'hour',
);
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
const [lineData, setLineData] = useState([]);
2025-05-20 18:01:38 +08:00
const [searchModalVisible, setSearchModalVisible] = useState(false);
const [spec_pie, setSpecPie] = useState({
type: 'pie',
2025-04-04 12:00:38 +08:00
data: [
{
id: 'id0',
values: pieData,
},
],
outerRadius: 0.8,
innerRadius: 0.5,
padAngle: 0.6,
valueField: 'value',
categoryField: 'type',
pie: {
style: {
cornerRadius: 10,
2024-03-23 21:24:39 +08:00
},
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',
2025-04-04 12:00:38 +08:00
data: [
{
id: 'barData',
values: lineData,
},
],
2024-03-23 21:24:39 +08:00
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: true,
legends: {
visible: true,
2024-04-18 19:37:52 +08:00
selectMode: 'single',
2024-03-23 21:24:39 +08:00
},
title: {
visible: true,
text: t('模型消耗分布'),
subtext: `${t('总计')}${renderQuota(consumeQuota, 2)}`,
2024-03-23 21:24:39 +08:00
},
bar: {
state: {
hover: {
stroke: '#000',
lineWidth: 1,
2024-01-07 18:31:14 +08:00
},
2024-03-23 21:24:39 +08:00
},
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
2024-03-23 21:24:39 +08:00
},
2024-01-07 18:31:14 +08:00
],
2024-03-23 21:24:39 +08:00
},
dimension: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => datum['rawQuota'] || 0,
2024-03-23 21:24:39 +08:00
},
],
updateContent: (array) => {
array.sort((a, b) => b.value - a.value);
let sum = 0;
for (let i = 0; i < array.length; i++) {
2025-04-04 12:00:38 +08:00
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);
2024-03-23 21:24:39 +08:00
}
array.unshift({
key: t('总计'),
value: renderQuota(sum, 4),
2024-03-23 21:24:39 +08:00
});
return array;
2024-01-07 18:31:14 +08:00
},
2024-03-23 21:24:39 +08:00
},
},
color: {
specified: modelColorMap,
},
});
2024-03-23 21:24:39 +08:00
// 添加一个新的状态来存储模型-颜色映射
const [modelColors, setModelColors] = useState({});
2025-05-20 18:01:38 +08:00
// 显示搜索Modal
const showSearchModal = () => {
setSearchModalVisible(true);
};
// 关闭搜索Modal
const handleCloseModal = () => {
setSearchModalVisible(false);
};
// 搜索Modal确认按钮
const handleSearchConfirm = () => {
refresh();
setSearchModalVisible(false);
};
const handleInputChange = (value, name) => {
if (name === 'data_export_default_time') {
setDataExportDefaultTime(value);
return;
}
setInputs((inputs) => ({ ...inputs, [name]: value }));
2024-03-23 21:24:39 +08:00
};
const loadQuotaData = async () => {
2024-03-23 21:24:39 +08:00
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}`;
2024-03-23 21:24:39 +08:00
}
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,
});
}
// sort created_at
data.sort((a, b) => a.created_at - b.created_at);
updateChartData(data);
} else {
showError(message);
2024-03-23 21:24:39 +08:00
}
} finally {
setLoading(false);
2024-03-23 21:24:39 +08:00
}
};
2024-01-07 18:31:14 +08:00
2024-03-23 21:24:39 +08:00
const refresh = async () => {
await loadQuotaData();
2024-03-23 21:24:39 +08:00
};
2024-01-07 18:31:14 +08:00
2024-03-23 21:24:39 +08:00
const initChart = async () => {
await loadQuotaData();
2024-03-23 21:24:39 +08:00
};
2024-01-07 18:31:14 +08:00
const updateChartData = (data) => {
let newPieData = [];
let newLineData = [];
let totalQuota = 0;
let totalTimes = 0;
let uniqueModels = new Set();
let totalTokens = 0;
// 收集所有唯一的模型名称
2025-04-04 12:00:38 +08:00
data.forEach((item) => {
uniqueModels.add(item.model_name);
totalTokens += item.token_used;
totalQuota += item.quota;
totalTimes += item.count;
});
// 处理颜色映射
const newModelColors = {};
Array.from(uniqueModels).forEach((modelName) => {
2025-04-04 12:00:38 +08:00
newModelColors[modelName] =
modelColorMap[modelName] ||
modelColors[modelName] ||
modelToColor(modelName);
});
setModelColors(newModelColors);
// 按时间和模型聚合数据
let aggregatedData = new Map();
2025-04-04 12:00:38 +08:00
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,
2025-04-04 12:00:38 +08:00
count: 0,
2024-03-23 21:24:39 +08:00
});
}
2025-04-04 12:00:38 +08:00
const existing = aggregatedData.get(key);
existing.quota += item.quota;
existing.count += item.count;
});
// 处理饼图数据
let modelTotals = new Map();
for (let [_, value] of aggregatedData) {
if (!modelTotals.has(value.model)) {
modelTotals.set(value.model, 0);
}
modelTotals.set(value.model, modelTotals.get(value.model) + value.count);
}
newPieData = Array.from(modelTotals).map(([model, count]) => ({
type: model,
2025-04-04 12:00:38 +08:00
value: count,
}));
// 生成时间点序列
2025-04-04 12:00:38 +08:00
let timePoints = Array.from(
new Set([...aggregatedData.values()].map((d) => d.time)),
);
if (timePoints.length < 7) {
2025-04-04 12:00:38 +08:00
const lastTime = Math.max(...data.map((item) => item.created_at));
const interval =
dataExportDefaultTime === 'hour'
? 3600
: dataExportDefaultTime === 'day'
? 86400
: 604800;
timePoints = Array.from({ length: 7 }, (_, i) =>
timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
);
}
// 生成柱状图数据
2025-04-04 12:00:38 +08:00
timePoints.forEach((time) => {
// 为每个时间点收集所有模型的数据
2025-04-04 12:00:38 +08:00
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,
2025-04-04 12:00:38 +08:00
Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0,
};
});
2025-04-04 12:00:38 +08:00
// 计算该时间点的总计
const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
2025-04-04 12:00:38 +08:00
// 按照 rawQuota 从大到小排序
timeData.sort((a, b) => b.rawQuota - a.rawQuota);
2025-04-04 12:00:38 +08:00
// 为每个数据点添加该时间的总计
2025-04-04 12:00:38 +08:00
timeData = timeData.map((item) => ({
...item,
2025-04-04 12:00:38 +08:00
TimeSum: timeSum,
}));
2025-04-04 12:00:38 +08:00
// 将排序后的数据添加到 newLineData
newLineData.push(...timeData);
});
2024-01-07 18:31:14 +08:00
// 排序
newPieData.sort((a, b) => b.value - a.value);
newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
2024-01-10 17:49:55 +08:00
// 更新图表配置和数据
2025-04-04 12:00:38 +08:00
setSpecPie((prev) => ({
...prev,
data: [{ id: 'id0', values: newPieData }],
title: {
...prev.title,
2025-04-04 12:00:38 +08:00
subtext: `${t('总计')}${renderNumber(totalTimes)}`,
},
color: {
2025-04-04 12:00:38 +08:00
specified: newModelColors,
},
}));
2024-01-10 17:49:55 +08:00
2025-04-04 12:00:38 +08:00
setSpecLine((prev) => ({
...prev,
data: [{ id: 'barData', values: newLineData }],
title: {
...prev.title,
2025-04-04 12:00:38 +08:00
subtext: `${t('总计')}${renderQuota(totalQuota, 2)}`,
},
color: {
2025-04-04 12:00:38 +08:00
specified: newModelColors,
},
}));
2025-04-04 12:00:38 +08:00
setPieData(newPieData);
setLineData(newLineData);
setConsumeQuota(totalQuota);
setTimes(totalTimes);
setConsumeTokens(totalTokens);
};
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
2025-04-04 12:00:38 +08:00
const { success, message, data } = res.data;
if (success) {
2025-04-04 12:00:38 +08:00
userDispatch({ type: 'login', payload: data });
} else {
showError(message);
}
2024-03-23 21:24:39 +08:00
};
2024-01-10 17:49:55 +08:00
2024-03-23 21:24:39 +08:00
useEffect(() => {
2025-04-04 12:00:38 +08:00
getUserData();
2024-03-23 21:24:39 +08:00
if (!initialized.current) {
initVChartSemiTheme({
isWatchingThemeSwitch: true,
});
initialized.current = true;
initChart();
}
2024-03-23 21:24:39 +08:00
}, []);
2025-05-20 18:01:38 +08:00
// 数据卡片信息
const statsData = [
{
title: t('当前余额'),
value: renderQuota(userState?.user?.quota),
icon: <IconMoneyExchangeStroked size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-blue-50',
avatarColor: 'blue',
onClick: () => navigate('/console/topup'),
2025-05-20 18:01:38 +08:00
},
{
title: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
icon: <IconHistogram size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-purple-50',
avatarColor: 'purple',
2025-05-20 18:01:38 +08:00
},
{
title: t('请求次数'),
value: userState.user?.request_count,
icon: <IconRotate size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-green-50',
avatarColor: 'green',
2025-05-20 18:01:38 +08:00
},
{
title: t('统计额度'),
value: renderQuota(consumeQuota),
icon: <IconCoinMoneyStroked size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-yellow-50',
avatarColor: 'yellow',
2025-05-20 18:01:38 +08:00
},
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens,
icon: <IconTextStroked size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-pink-50',
avatarColor: 'pink',
2025-05-20 18:01:38 +08:00
},
{
title: t('统计次数'),
value: times,
icon: <IconPulse size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-teal-50',
avatarColor: 'cyan',
2025-05-20 18:01:38 +08:00
},
{
title: t('平均RPM'),
value: (
times /
((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000)
).toFixed(3),
icon: <IconStopwatchStroked size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-indigo-50',
avatarColor: 'indigo',
2025-05-20 18:01:38 +08:00
},
{
title: t('平均TPM'),
value: (() => {
const tpm = consumeTokens /
((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000);
return isNaN(tpm) ? '0' : tpm.toFixed(3);
})(),
icon: <IconTypograph size="large" />,
2025-05-20 18:01:38 +08:00
color: 'bg-orange-50',
avatarColor: 'orange',
2025-05-20 18:01:38 +08:00
},
];
// 获取问候语
const getGreeting = () => {
const hours = new Date().getHours();
let greeting = '';
2025-05-20 18:01:38 +08:00
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('晚上好');
}
2025-05-20 18:01:38 +08:00
const username = userState?.user?.username || '';
return `👋${greeting}${username}`;
};
2024-03-23 21:24:39 +08:00
return (
<div className="bg-gray-50 h-full">
<div className="flex items-center justify-between mb-4">
2025-05-20 18:01:38 +08:00
<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 text-white hover:bg-green-600 !rounded-full"
/>
<IconButton
icon={<IconRefresh />}
onClick={refresh}
loading={loading}
className="bg-blue-500 text-white hover:bg-blue-600 !rounded-full"
/>
</div>
</div>
{/* 搜索条件Modal */}
<Modal
title={t('搜索条件')}
visible={searchModalVisible}
onOk={handleSearchConfirm}
onCancel={handleCloseModal}
closeOnEsc={true}
size={isMobile() ? 'full-width' : 'small'}
2025-05-20 18:01:38 +08:00
centered
>
<Form ref={formRef} layout='vertical' className="w-full">
<Form.DatePicker
field='start_timestamp'
label={t('起始时间')}
className="w-full mb-2 !rounded-lg"
2025-05-20 18:01:38 +08:00
initValue={start_timestamp}
value={start_timestamp}
type='dateTime'
name='start_timestamp'
size='large'
2025-05-20 18:01:38 +08:00
onChange={(value) => handleInputChange(value, 'start_timestamp')}
/>
<Form.DatePicker
field='end_timestamp'
label={t('结束时间')}
className="w-full mb-2 !rounded-lg"
2025-05-20 18:01:38 +08:00
initValue={end_timestamp}
value={end_timestamp}
type='dateTime'
name='end_timestamp'
size='large'
2025-05-20 18:01:38 +08:00
onChange={(value) => handleInputChange(value, 'end_timestamp')}
/>
<Form.Select
field='data_export_default_time'
label={t('时间粒度')}
className="w-full mb-2 !rounded-lg"
2025-05-20 18:01:38 +08:00
initValue={dataExportDefaultTime}
placeholder={t('时间粒度')}
name='data_export_default_time'
size='large'
2025-05-20 18:01:38 +08:00
optionList={[
{ label: t('小时'), value: 'hour' },
{ label: t('天'), value: 'day' },
{ label: t('周'), value: 'week' },
]}
onChange={(value) => handleInputChange(value, 'data_export_default_time')}
/>
{isAdminUser && (
<Form.Input
field='username'
label={t('用户名称')}
className="w-full mb-2 !rounded-lg"
2025-05-20 18:01:38 +08:00
value={username}
placeholder={t('可选值')}
name='username'
size='large'
2025-05-20 18:01:38 +08:00
onChange={(value) => handleInputChange(value, 'username')}
/>
)}
</Form>
</Modal>
<Spin spinning={loading}>
<div className="mb-4">
2025-05-20 18:01:38 +08:00
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{statsData.map((stat, idx) => (
<Card
key={idx}
shadows='hover'
className={`${stat.color} border-0 !rounded-2xl w-full`}
headerLine={false}
onClick={stat.onClick}
>
2025-05-20 18:01:38 +08:00
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color={stat.avatarColor}
>
{stat.icon}
</Avatar>
2025-05-20 18:01:38 +08:00
<div>
<div className="text-sm text-gray-500">{stat.title}</div>
<div className="text-xl font-semibold">{stat.value}</div>
</div>
2025-05-20 18:01:38 +08:00
</div>
</Card>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<Card shadows='hover' className="shadow-sm !rounded-2xl" headerLine={true} title={t('模型消耗分布')}>
<div style={{ height: 400 }}>
<VChart
spec={spec_line}
option={{ mode: 'desktop-browser' }}
/>
</div>
</Card>
<Card shadows='hover' className="shadow-sm !rounded-2xl" headerLine={true} title={t('模型调用次数占比')}>
<div style={{ height: 400 }}>
<VChart
spec={spec_pie}
option={{ mode: 'desktop-browser' }}
/>
</div>
</Card>
</div>
</Spin>
</div>
2024-03-23 21:24:39 +08:00
);
2024-01-07 18:31:14 +08:00
};
export default Detail;