629 lines
15 KiB
React
Vendored
629 lines
15 KiB
React
Vendored
/*
|
||
Copyright (C) 2025 QuantumNous
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as
|
||
published by the Free Software Foundation, either version 3 of the
|
||
License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
For commercial licensing, please contact support@quantumnous.com
|
||
*/
|
||
|
||
import { useState, useCallback, useEffect } from 'react';
|
||
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||
import {
|
||
modelColorMap,
|
||
renderNumber,
|
||
renderQuota,
|
||
modelToColor,
|
||
getQuotaWithUnit,
|
||
} from '../../helpers';
|
||
import {
|
||
processRawData,
|
||
calculateTrendData,
|
||
aggregateDataByTimeAndModel,
|
||
generateChartTimePoints,
|
||
updateChartSpec,
|
||
updateMapValue,
|
||
initializeMaps,
|
||
processUserData,
|
||
} from '../../helpers/dashboard';
|
||
|
||
const USER_COLORS = [
|
||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
|
||
];
|
||
|
||
export const useDashboardCharts = (
|
||
dataExportDefaultTime,
|
||
setTrendData,
|
||
setConsumeQuota,
|
||
setTimes,
|
||
setConsumeTokens,
|
||
setPieData,
|
||
setLineData,
|
||
setModelColors,
|
||
t,
|
||
) => {
|
||
// ========== 图表规格状态 ==========
|
||
const [spec_pie, setSpecPie] = useState({
|
||
type: 'pie',
|
||
data: [
|
||
{
|
||
id: 'id0',
|
||
values: [{ type: 'null', value: '0' }],
|
||
},
|
||
],
|
||
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(0)}`,
|
||
},
|
||
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: [],
|
||
},
|
||
],
|
||
xField: 'Time',
|
||
yField: 'Usage',
|
||
seriesField: 'Model',
|
||
stack: true,
|
||
legends: {
|
||
visible: true,
|
||
selectMode: 'single',
|
||
},
|
||
title: {
|
||
visible: true,
|
||
text: t('模型消耗分布'),
|
||
subtext: `${t('总计')}:${renderQuota(0, 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,
|
||
},
|
||
});
|
||
|
||
const [spec_model_line, setSpecModelLine] = useState({
|
||
type: 'line',
|
||
data: [
|
||
{
|
||
id: 'lineData',
|
||
values: [],
|
||
},
|
||
],
|
||
xField: 'Time',
|
||
yField: 'Count',
|
||
seriesField: 'Model',
|
||
legends: {
|
||
visible: true,
|
||
selectMode: 'single',
|
||
},
|
||
title: {
|
||
visible: true,
|
||
text: t('调用趋势'),
|
||
subtext: '',
|
||
},
|
||
tooltip: {
|
||
mark: {
|
||
content: [
|
||
{
|
||
key: (datum) => datum['Model'],
|
||
value: (datum) => renderNumber(datum['Count']),
|
||
},
|
||
],
|
||
},
|
||
dimension: {
|
||
content: [
|
||
{
|
||
key: (datum) => datum['Model'],
|
||
value: (datum) => datum['Count'] || 0,
|
||
},
|
||
],
|
||
updateContent: (array) => {
|
||
array.sort((a, b) => b.value - a.value);
|
||
let sum = 0;
|
||
for (let i = 0; i < array.length; i++) {
|
||
let value = parseFloat(array[i].value);
|
||
if (isNaN(value)) value = 0;
|
||
sum += value;
|
||
array[i].value = renderNumber(value);
|
||
}
|
||
array.unshift({
|
||
key: t('总计'),
|
||
value: renderNumber(sum),
|
||
});
|
||
return array;
|
||
},
|
||
},
|
||
},
|
||
color: {
|
||
specified: modelColorMap,
|
||
},
|
||
});
|
||
|
||
const [spec_rank_bar, setSpecRankBar] = useState({
|
||
type: 'bar',
|
||
data: [
|
||
{
|
||
id: 'rankData',
|
||
values: [],
|
||
},
|
||
],
|
||
xField: 'Model',
|
||
yField: 'Count',
|
||
seriesField: 'Model',
|
||
legends: {
|
||
visible: true,
|
||
selectMode: 'single',
|
||
},
|
||
title: {
|
||
visible: true,
|
||
text: t('模型调用次数排行'),
|
||
subtext: '',
|
||
},
|
||
bar: {
|
||
state: {
|
||
hover: {
|
||
stroke: '#000',
|
||
lineWidth: 1,
|
||
},
|
||
},
|
||
},
|
||
tooltip: {
|
||
mark: {
|
||
content: [
|
||
{
|
||
key: (datum) => datum['Model'],
|
||
value: (datum) => renderNumber(datum['Count']),
|
||
},
|
||
],
|
||
},
|
||
},
|
||
color: {
|
||
specified: modelColorMap,
|
||
},
|
||
});
|
||
|
||
// ========== Admin: 用户消耗排行 ==========
|
||
const [spec_user_rank, setSpecUserRank] = useState({
|
||
type: 'bar',
|
||
data: [{ id: 'userRankData', values: [] }],
|
||
xField: 'rawQuota',
|
||
yField: 'User',
|
||
seriesField: 'User',
|
||
direction: 'horizontal',
|
||
legends: { visible: false },
|
||
title: {
|
||
visible: true,
|
||
text: t('用户消耗排行'),
|
||
subtext: '',
|
||
},
|
||
bar: {
|
||
state: { hover: { stroke: '#000', lineWidth: 1 } },
|
||
},
|
||
label: {
|
||
visible: true,
|
||
position: 'outside',
|
||
formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2),
|
||
},
|
||
axes: [{
|
||
orient: 'left',
|
||
type: 'band',
|
||
label: { visible: true },
|
||
}, {
|
||
orient: 'bottom',
|
||
type: 'linear',
|
||
visible: false,
|
||
}],
|
||
tooltip: {
|
||
mark: {
|
||
content: [{
|
||
key: (datum) => datum['User'],
|
||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||
}],
|
||
},
|
||
},
|
||
color: { type: 'ordinal', range: USER_COLORS },
|
||
});
|
||
|
||
// ========== Admin: 用户消耗趋势 ==========
|
||
const [spec_user_trend, setSpecUserTrend] = useState({
|
||
type: 'area',
|
||
data: [{ id: 'userTrendData', values: [] }],
|
||
xField: 'Time',
|
||
yField: 'rawQuota',
|
||
seriesField: 'User',
|
||
stack: false,
|
||
legends: { visible: true, selectMode: 'single' },
|
||
title: {
|
||
visible: true,
|
||
text: t('用户消耗趋势'),
|
||
subtext: '',
|
||
},
|
||
axes: [{
|
||
orient: 'left',
|
||
label: {
|
||
formatMethod: (value) => renderQuota(value, 2),
|
||
},
|
||
}],
|
||
area: { style: { fillOpacity: 0.15 } },
|
||
line: { style: { lineWidth: 2 } },
|
||
point: { visible: false },
|
||
tooltip: {
|
||
mark: {
|
||
content: [{
|
||
key: (datum) => datum['User'],
|
||
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||
}],
|
||
},
|
||
dimension: {
|
||
content: [{
|
||
key: (datum) => datum['User'],
|
||
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++) {
|
||
let value = parseFloat(array[i].value);
|
||
if (isNaN(value)) value = 0;
|
||
sum += value;
|
||
array[i].value = renderQuota(value, 4);
|
||
}
|
||
array.unshift({
|
||
key: t('总计'),
|
||
value: renderQuota(sum, 4),
|
||
});
|
||
return array;
|
||
},
|
||
},
|
||
},
|
||
color: { type: 'ordinal', range: USER_COLORS },
|
||
});
|
||
|
||
// ========== 数据处理函数 ==========
|
||
const generateModelColors = useCallback((uniqueModels, modelColors) => {
|
||
const newModelColors = {};
|
||
Array.from(uniqueModels).forEach((modelName) => {
|
||
newModelColors[modelName] =
|
||
modelColorMap[modelName] ||
|
||
modelColors[modelName] ||
|
||
modelToColor(modelName);
|
||
});
|
||
return newModelColors;
|
||
}, []);
|
||
|
||
const updateChartData = useCallback(
|
||
(data) => {
|
||
const processedData = processRawData(
|
||
data,
|
||
dataExportDefaultTime,
|
||
initializeMaps,
|
||
updateMapValue,
|
||
);
|
||
|
||
const {
|
||
totalQuota,
|
||
totalTimes,
|
||
totalTokens,
|
||
uniqueModels,
|
||
timePoints,
|
||
timeQuotaMap,
|
||
timeTokensMap,
|
||
timeCountMap,
|
||
} = processedData;
|
||
|
||
const trendDataResult = calculateTrendData(
|
||
timePoints,
|
||
timeQuotaMap,
|
||
timeTokensMap,
|
||
timeCountMap,
|
||
dataExportDefaultTime,
|
||
);
|
||
setTrendData(trendDataResult);
|
||
|
||
const newModelColors = generateModelColors(uniqueModels, {});
|
||
setModelColors(newModelColors);
|
||
|
||
const aggregatedData = aggregateDataByTimeAndModel(
|
||
data,
|
||
dataExportDefaultTime,
|
||
);
|
||
|
||
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,
|
||
dataExportDefaultTime,
|
||
);
|
||
|
||
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',
|
||
);
|
||
|
||
// ===== 模型调用次数折线图 =====
|
||
let modelLineData = [];
|
||
chartTimePoints.forEach((time) => {
|
||
const timeData = Array.from(uniqueModels).map((model) => {
|
||
const key = `${time}-${model}`;
|
||
const aggregated = aggregatedData.get(key);
|
||
return {
|
||
Time: time,
|
||
Model: model,
|
||
Count: aggregated?.count || 0,
|
||
};
|
||
});
|
||
modelLineData.push(...timeData);
|
||
});
|
||
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||
|
||
// ===== 模型调用次数排行柱状图 =====
|
||
const MAX_RANK_MODELS = 20;
|
||
const allRankData = Array.from(modelTotals)
|
||
.map(([model, count]) => ({
|
||
Model: model,
|
||
Count: count,
|
||
}))
|
||
.sort((a, b) => b.Count - a.Count);
|
||
|
||
let rankData;
|
||
if (allRankData.length > MAX_RANK_MODELS) {
|
||
const topModels = allRankData.slice(0, MAX_RANK_MODELS);
|
||
const otherCount = allRankData
|
||
.slice(MAX_RANK_MODELS)
|
||
.reduce((sum, item) => sum + item.Count, 0);
|
||
rankData = [...topModels, { Model: t('其他'), Count: otherCount }];
|
||
} else {
|
||
rankData = allRankData;
|
||
}
|
||
|
||
updateChartSpec(
|
||
setSpecModelLine,
|
||
modelLineData,
|
||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||
newModelColors,
|
||
'lineData',
|
||
);
|
||
|
||
updateChartSpec(
|
||
setSpecRankBar,
|
||
rankData,
|
||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||
newModelColors,
|
||
'rankData',
|
||
);
|
||
|
||
setPieData(newPieData);
|
||
setLineData(newLineData);
|
||
setConsumeQuota(totalQuota);
|
||
setTimes(totalTimes);
|
||
setConsumeTokens(totalTokens);
|
||
},
|
||
[
|
||
dataExportDefaultTime,
|
||
setTrendData,
|
||
generateModelColors,
|
||
setModelColors,
|
||
setPieData,
|
||
setLineData,
|
||
setConsumeQuota,
|
||
setTimes,
|
||
setConsumeTokens,
|
||
t,
|
||
],
|
||
);
|
||
|
||
// ========== 用户维度图表数据处理 ==========
|
||
const updateUserChartData = useCallback(
|
||
(data) => {
|
||
const { rankingData, trendData: userTrend } = processUserData(
|
||
data,
|
||
dataExportDefaultTime,
|
||
10,
|
||
);
|
||
|
||
const userRankValues = rankingData.map((item) => ({
|
||
User: item.User,
|
||
rawQuota: item.Quota,
|
||
Quota: getQuotaWithUnit(item.Quota, 4),
|
||
})).sort((a, b) => b.rawQuota - a.rawQuota);
|
||
|
||
const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0);
|
||
|
||
setSpecUserRank((prev) => ({
|
||
...prev,
|
||
data: [{ id: 'userRankData', values: userRankValues }],
|
||
title: {
|
||
...prev.title,
|
||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||
},
|
||
}));
|
||
|
||
const userTrendValues = userTrend.map((item) => ({
|
||
Time: item.Time,
|
||
User: item.User,
|
||
rawQuota: item.Quota,
|
||
Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0,
|
||
}));
|
||
|
||
setSpecUserTrend((prev) => ({
|
||
...prev,
|
||
data: [{ id: 'userTrendData', values: userTrendValues }],
|
||
title: {
|
||
...prev.title,
|
||
subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`,
|
||
},
|
||
}));
|
||
},
|
||
[dataExportDefaultTime, t],
|
||
);
|
||
|
||
// ========== 初始化图表主题 ==========
|
||
useEffect(() => {
|
||
initVChartSemiTheme({
|
||
isWatchingThemeSwitch: true,
|
||
});
|
||
}, []);
|
||
|
||
return {
|
||
spec_pie,
|
||
spec_line,
|
||
spec_model_line,
|
||
spec_rank_bar,
|
||
spec_user_rank,
|
||
spec_user_trend,
|
||
updateChartData,
|
||
updateUserChartData,
|
||
generateModelColors,
|
||
};
|
||
};
|