Files
chaos-api/web/src/hooks/dashboard/useDashboardCharts.js
T
t0ng7u 45368deac3 ️ perf: Defer Visactor chart libs to dashboard; minimize home bundle
Home started loading `/assets/visactor-*.js` due to static imports of `@visactor/react-vchart` and the Semi theme in dashboard components/hooks. This change moves chart dependencies to lazy/dynamic imports so they load only on dashboard routes.

Changes
- StatsCards.jsx: replace static `VChart` import with `React.lazy` + `Suspense` (fallback: null)
- ChartsPanel.jsx: replace static `VChart` import with `React.lazy` + `Suspense` (fallback: null)
- useDashboardCharts.js: remove static `initVChartSemiTheme` import; dynamically import and initialize the theme inside `useEffect` with a cancel guard

Behavior
- Home page no longer downloads `visactor` chunks on first load
- Chart libraries are fetched only when visiting `/console` (dashboard)
- No functional changes to chart rendering

Files
- web/src/components/dashboard/StatsCards.jsx
- web/src/components/dashboard/ChartsPanel.jsx
- web/src/hooks/dashboard/useDashboardCharts.js

Verification
- Build the app (`npm run build`) and open `/`: no `/assets/visactor-*.js` requests
- Navigate to `/console`: `visactor` chunks load and charts render as expected

Breaking Changes
- None

Follow-ups
- If needed, further trim homepage bundle by reducing heavy icon sets on the hero section
2025-08-17 01:22:44 +08:00

446 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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 {
modelColorMap,
renderNumber,
renderQuota,
modelToColor,
getQuotaWithUnit
} from '../../helpers';
import {
processRawData,
calculateTrendData,
aggregateDataByTimeAndModel,
generateChartTimePoints,
updateChartSpec,
updateMapValue,
initializeMaps
} from '../../helpers/dashboard';
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']),
},
],
},
},
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,
},
});
// ========== 数据处理函数 ==========
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 rankData = Array.from(modelTotals)
.map(([model, count]) => ({
Model: model,
Count: count,
}))
.sort((a, b) => b.Count - a.Count);
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
]);
// ========== 初始化图表主题 ==========
useEffect(() => {
// 动态加载 visactor 主题,避免首页首屏加载
let canceled = false;
(async () => {
try {
const mod = await import('@visactor/vchart-semi-theme');
if (!canceled && mod?.initVChartSemiTheme) {
mod.initVChartSemiTheme({ isWatchingThemeSwitch: true });
}
} catch (e) {
// 忽略加载失败,图表页再尝试
}
})();
return () => { canceled = true; };
}, []);
return {
// 图表规格
spec_pie,
spec_line,
spec_model_line,
spec_rank_bar,
// 函数
updateChartData,
generateModelColors
};
};