Files
new-api-hunter/web/src/pages/Detail/index.js
t0ng7u 505962ae64 ♻️ refactor: Extract scroll effect logic into reusable ScrollableContainer component
- Create new ScrollableContainer component in @/components/common/ui
  - Provides automatic scroll detection and fade indicator
  - Supports customizable height, styling, and event callbacks
  - Includes comprehensive PropTypes for type safety
  - Optimized with useCallback for better performance

- Refactor Detail page to use ScrollableContainer
  - Remove manual scroll detection functions (checkApiScrollable, checkCardScrollable)
  - Remove scroll event handlers (handleApiScroll, handleCardScroll)
  - Remove scroll-related refs and state variables
  - Replace all card scroll containers with ScrollableContainer component
    * API info card
    * System announcements card
    * FAQ card
    * Uptime monitoring card (both single and multi-tab scenarios)

- Benefits:
  - Improved code reusability and maintainability
  - Reduced code duplication across components
  - Consistent scroll behavior throughout the application
  - Easier to maintain and extend scroll functionality

Breaking changes: None
Migration: Existing scroll behavior is preserved with no user-facing changes
2025-07-20 12:51:18 +08:00

1611 lines
51 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 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, Server, Bell, HelpCircle, ExternalLink } from 'lucide-react';
import { marked } from 'marked';
import {
Card,
Form,
Spin,
Button,
Modal,
Avatar,
Tabs,
TabPane,
Empty,
Tag,
Timeline,
Collapse,
Progress,
Divider,
Skeleton
} from '@douyinfe/semi-ui';
import ScrollableContainer from '../../components/common/ui/ScrollableContainer';
import {
IconRefresh,
IconSearch,
IconMoneyExchangeStroked,
IconHistogram,
IconCoinMoneyStroked,
IconTextStroked,
IconPulse,
IconStopwatchStroked,
IconTypograph,
IconPieChart2Stroked,
IconPlus,
IconMinus,
IconSend
} from '@douyinfe/semi-icons';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
import { VChart } from '@visactor/react-vchart';
import {
API,
isAdmin,
showError,
showSuccess,
showWarning,
timestamp2string,
timestamp2string1,
getQuotaWithUnit,
modelColorMap,
renderNumber,
renderQuota,
modelToColor,
copy,
getRelativeTime
} from '../../helpers';
import { useIsMobile } from '../../hooks/common/useIsMobile.js';
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();
const isMobile = useIsMobile();
// ========== Hooks - Refs ==========
const formRef = useRef();
const initialized = useRef(false);
// ========== 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";
const ILLUSTRATION_SIZE = { width: 96, height: 96 };
// ========== Constants ==========
let now = new Date();
const isAdminUser = isAdmin();
// ========== Panel enable flags ==========
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
const announcementsEnabled = statusState?.status?.announcements_enabled ?? true;
const faqEnabled = statusState?.status?.faq_enabled ?? true;
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
const hasApiInfoPanel = apiInfoEnabled;
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
// ========== 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 [greetingVisible, setGreetingVisible] = 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 [modelColors, setModelColors] = useState({});
const [activeChartTab, setActiveChartTab] = useState('1');
const [searchModalVisible, setSearchModalVisible] = useState(false);
const [trendData, setTrendData] = useState({
balance: [],
usedQuota: [],
requestCount: [],
times: [],
consumeQuota: [],
tokens: [],
rpm: [],
tpm: []
});
// ========== Uptime data ==========
const [uptimeData, setUptimeData] = useState([]);
const [uptimeLoading, setUptimeLoading] = useState(false);
const [activeUptimeTab, setActiveUptimeTab] = useState('');
// ========== 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,
},
});
// 模型消耗趋势折线图
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,
},
});
// ========== Hooks - Memoized Values ==========
const performanceMetrics = useMemo(() => {
const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
const avgRPM = isNaN(times / timeDiff) ? '0' : (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 />,
avatarColor: 'blue',
onClick: () => navigate('/console/topup'),
trendData: [],
trendColor: '#3b82f6'
},
{
title: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
icon: <IconHistogram />,
avatarColor: 'purple',
trendData: [],
trendColor: '#8b5cf6'
}
]
},
{
title: createSectionTitle(Activity, t('使用统计')),
color: 'bg-green-50',
items: [
{
title: t('请求次数'),
value: userState.user?.request_count,
icon: <IconSend />,
avatarColor: 'green',
trendData: [],
trendColor: '#10b981'
},
{
title: t('统计次数'),
value: times,
icon: <IconPulse />,
avatarColor: 'cyan',
trendData: trendData.times,
trendColor: '#06b6d4'
}
]
},
{
title: createSectionTitle(Zap, t('资源消耗')),
color: 'bg-yellow-50',
items: [
{
title: t('统计额度'),
value: renderQuota(consumeQuota),
icon: <IconCoinMoneyStroked />,
avatarColor: 'yellow',
trendData: trendData.consumeQuota,
trendColor: '#f59e0b'
},
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens,
icon: <IconTextStroked />,
avatarColor: 'pink',
trendData: trendData.tokens,
trendColor: '#ec4899'
}
]
},
{
title: createSectionTitle(Gauge, t('性能指标')),
color: 'bg-indigo-50',
items: [
{
title: t('平均RPM'),
value: performanceMetrics.avgRPM,
icon: <IconStopwatchStroked />,
avatarColor: 'indigo',
trendData: trendData.rpm,
trendColor: '#6366f1'
},
{
title: t('平均TPM'),
value: performanceMetrics.avgTPM,
icon: <IconTypograph />,
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', 'noopener,noreferrer');
}, []);
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);
const startTime = Date.now();
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 {
const elapsed = Date.now() - startTime;
const remainingTime = Math.max(0, 500 - elapsed);
setTimeout(() => {
setLoading(false);
}, remainingTime);
}
}, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
const loadUptimeData = useCallback(async () => {
setUptimeLoading(true);
try {
const res = await API.get('/api/uptime/status');
const { success, message, data } = res.data;
if (success) {
setUptimeData(data || []);
if (data && data.length > 0 && !activeUptimeTab) {
setActiveUptimeTab(data[0].categoryName);
}
} else {
showError(message);
}
} catch (err) {
console.error(err);
} finally {
setUptimeLoading(false);
}
}, [activeUptimeTab]);
const refresh = useCallback(async () => {
await Promise.all([loadQuotaData(), loadUptimeData()]);
}, [loadQuotaData, loadUptimeData]);
const handleSearchConfirm = useCallback(() => {
refresh();
setSearchModalVisible(false);
}, [refresh]);
const initChart = useCallback(async () => {
await loadQuotaData();
await loadUptimeData();
}, [loadQuotaData, loadUptimeData]);
const showSearchModal = useCallback(() => {
setSearchModalVisible(true);
}, []);
const handleCloseModal = useCallback(() => {
setSearchModalVisible(false);
}, []);
useEffect(() => {
const timer = setTimeout(() => {
setGreetingVisible(true);
}, 100);
return () => clearTimeout(timer);
}, []);
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'
);
// ===== 模型调用次数折线图 =====
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);
}, [
processRawData, calculateTrendData, generateModelColors, aggregateDataByTimeAndModel,
generateChartTimePoints, updateChartSpec, updateMapValue, t
]);
// ========== Status Data Management ==========
const announcementLegendData = useMemo(() => [
{ color: 'grey', label: t('默认'), type: 'default' },
{ color: 'blue', label: t('进行中'), type: 'ongoing' },
{ color: 'green', label: t('成功'), type: 'success' },
{ color: 'orange', label: t('警告'), type: 'warning' },
{ color: 'red', label: t('异常'), type: 'error' }
], [t]);
const uptimeStatusMap = useMemo(() => ({
1: { color: '#10b981', label: t('正常'), text: t('可用率') }, // UP
0: { color: '#ef4444', label: t('异常'), text: t('有异常') }, // DOWN
2: { color: '#f59e0b', label: t('高延迟'), text: t('高延迟') }, // PENDING
3: { color: '#3b82f6', label: t('维护中'), text: t('维护中') } // MAINTENANCE
}), [t]);
const uptimeLegendData = useMemo(() =>
Object.entries(uptimeStatusMap).map(([status, info]) => ({
status: Number(status),
color: info.color,
label: info.label
})), [uptimeStatusMap]);
const getUptimeStatusColor = useCallback((status) =>
uptimeStatusMap[status]?.color || '#8b9aa7',
[uptimeStatusMap]);
const getUptimeStatusText = useCallback((status) =>
uptimeStatusMap[status]?.text || t('未知'),
[uptimeStatusMap, t]);
const apiInfoData = useMemo(() => {
return statusState?.status?.api_info || [];
}, [statusState?.status?.api_info]);
const announcementData = useMemo(() => {
const announcements = statusState?.status?.announcements || [];
return announcements.map(item => ({
...item,
time: getRelativeTime(item.publishDate)
}));
}, [statusState?.status?.announcements]);
const faqData = useMemo(() => {
return statusState?.status?.faq || [];
}, [statusState?.status?.faq]);
const renderMonitorList = useCallback((monitors) => {
if (!monitors || monitors.length === 0) {
return (
<div className="flex justify-center items-center py-4">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无监控数据')}
/>
</div>
);
}
const grouped = {};
monitors.forEach((m) => {
const g = m.group || '';
if (!grouped[g]) grouped[g] = [];
grouped[g].push(m);
});
const renderItem = (monitor, idx) => (
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: getUptimeStatusColor(monitor.status) }}
/>
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
</div>
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
<div className="flex-1">
<Progress
percent={(monitor.uptime || 0) * 100}
showInfo={false}
aria-label={`${monitor.name} uptime`}
stroke={getUptimeStatusColor(monitor.status)}
/>
</div>
</div>
</div>
);
return Object.entries(grouped).map(([gname, list]) => (
<div key={gname || 'default'} className="mb-2">
{gname && (
<>
<div className="text-md font-semibold text-gray-500 px-2 py-1">
{gname}
</div>
<Divider />
</>
)}
{list.map(renderItem)}
</div>
));
}, [t, getUptimeStatusColor, getUptimeStatusText]);
// ========== Hooks - Effects ==========
useEffect(() => {
getUserData();
if (!initialized.current) {
initVChartSemiTheme({
isWatchingThemeSwitch: true,
});
initialized.current = true;
initChart();
}
}, []);
return (
<div className="bg-gray-50 h-full mt-[60px] px-2">
<div className="flex items-center justify-between mb-4">
<h2
className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
style={{ opacity: greetingVisible ? 1 : 0 }}
>
{getGreeting}
</h2>
<div className="flex gap-3">
<Button
type='tertiary'
icon={<IconSearch />}
onClick={showSearchModal}
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
/>
<Button
type='tertiary'
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>
<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">
<Skeleton
loading={loading}
active
placeholder={
<Skeleton.Paragraph
active
rows={1}
style={{ width: '65px', height: '24px', marginTop: '4px' }}
/>
}
>
{item.value}
</Skeleton>
</div>
</div>
</div>
{(loading || (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 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
<Card
{...CARD_PROPS}
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? '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>
<IconPulse />
{t('消耗趋势')}
</span>
} itemKey="2" />
<TabPane tab={
<span>
<IconPieChart2Stroked />
{t('调用次数分布')}
</span>
} itemKey="3" />
<TabPane tab={
<span>
<IconHistogram />
{t('调用次数排行')}
</span>
} itemKey="4" />
</Tabs>
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className="h-96 p-2">
{activeChartTab === '1' && (
<VChart
spec={spec_line}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '2' && (
<VChart
spec={spec_model_line}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '3' && (
<VChart
spec={spec_pie}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '4' && (
<VChart
spec={spec_rank_bar}
option={CHART_CONFIG}
/>
)}
</div>
</Card>
{hasApiInfoPanel && (
<Card
{...CARD_PROPS}
className="bg-gray-50 border-0 !rounded-2xl"
title={
<div className={FLEX_CENTER_GAP2}>
<Server size={16} />
{t('API信息')}
</div>
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight="24rem">
{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-small"
color={api.color}
>
{api.route.substring(0, 2)}
</Avatar>
</div>
<div className="flex-1">
<div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
<span className="text-sm font-medium text-gray-900 !font-bold break-all">
{api.route}
</span>
<div className="flex items-center gap-1 mt-1 lg:mt-0">
<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>
<Tag
prefixIcon={<ExternalLink size={12} />}
size="small"
color="white"
shape='circle'
onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
className="cursor-pointer hover:opacity-80 text-xs"
>
{t('跳转')}
</Tag>
</div>
</div>
<div
className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
</div>
<div className="text-gray-500">
{api.description}
</div>
</div>
</div>
<Divider />
</>
))
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')}
/>
</div>
)}
</ScrollableContainer>
</Card>
)}
</div>
</div>
{/* 系统公告和常见问答卡片 */}
{
hasInfoPanels && (
<div className="mb-4">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* 公告卡片 */}
{announcementsEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-2"
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
<div className="flex items-center gap-2">
<Bell size={16} />
{t('系统公告')}
<Tag color="white" shape="circle">
{t('显示最新20条')}
</Tag>
</div>
{/* 图例 */}
<div className="flex flex-wrap gap-3 text-xs">
{announcementLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
legend.color === 'blue' ? '#3b82f6' :
legend.color === 'green' ? '#10b981' :
legend.color === 'orange' ? '#f59e0b' :
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
}}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</div>
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight="24rem">
{announcementData.length > 0 ? (
<Timeline mode="alternate">
{announcementData.map((item, idx) => (
<Timeline.Item
key={idx}
type={item.type || 'default'}
time={item.time}
>
<div>
<div
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
/>
{item.extra && (
<div
className="text-xs text-gray-500"
dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
/>
)}
</div>
</Timeline.Item>
))}
</Timeline>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')}
/>
</div>
)}
</ScrollableContainer>
</Card>
)}
{/* 常见问答卡片 */}
{faqEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} />
{t('常见问答')}
</div>
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight="24rem">
{faqData.length > 0 ? (
<Collapse
accordion
expandIcon={<IconPlus />}
collapseIcon={<IconMinus />}
>
{faqData.map((item, index) => (
<Collapse.Panel
key={index}
header={item.question}
itemKey={index.toString()}
>
<div
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
/>
</Collapse.Panel>
))}
</Collapse>
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')}
/>
</div>
)}
</ScrollableContainer>
</Card>
)}
{/* 服务可用性卡片 */}
{uptimeEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<Gauge size={16} />
{t('服务可用性')}
</div>
<Button
icon={<IconRefresh />}
onClick={loadUptimeData}
loading={uptimeLoading}
size="small"
theme="borderless"
type='tertiary'
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
/>
</div>
}
bodyStyle={{ padding: 0 }}
>
{/* 内容区域 */}
<div className="relative">
<Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? (
uptimeData.length === 1 ? (
<ScrollableContainer maxHeight="24rem">
{renderMonitorList(uptimeData[0].monitors)}
</ScrollableContainer>
) : (
<Tabs
type="card"
collapsible
activeKey={activeUptimeTab}
onChange={setActiveUptimeTab}
size="small"
>
{uptimeData.map((group, groupIdx) => (
<TabPane
tab={
<span className="flex items-center gap-2">
<Gauge size={14} />
{group.categoryName}
<Tag
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
size='small'
shape='circle'
>
{group.monitors ? group.monitors.length : 0}
</Tag>
</span>
}
itemKey={group.categoryName}
key={groupIdx}
>
<ScrollableContainer maxHeight="21.5rem">
{renderMonitorList(group.monitors)}
</ScrollableContainer>
</TabPane>
))}
</Tabs>
)
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')}
/>
</div>
)}
</Spin>
</div>
{/* 图例 */}
{uptimeData.length > 0 && (
<div className="p-3 bg-gray-50 rounded-b-2xl">
<div className="flex flex-wrap gap-3 text-xs justify-center">
{uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: legend.color }}
/>
<span className="text-gray-600">{legend.label}</span>
</div>
))}
</div>
</div>
)}
</Card>
)}
</div>
</div>
)
}
</div >
);
};
export default Detail;