| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477 |
- 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 } from 'lucide-react';
- import {
- Card,
- Form,
- Spin,
- IconButton,
- Modal,
- Avatar,
- Tabs,
- TabPane,
- Empty,
- Tag,
- Timeline,
- Collapse,
- Progress,
- Divider
- } from '@douyinfe/semi-ui';
- import {
- IconRefresh,
- IconSearch,
- IconMoneyExchangeStroked,
- IconHistogram,
- IconRotate,
- IconCoinMoneyStroked,
- IconTextStroked,
- IconPulse,
- IconStopwatchStroked,
- IconTypograph,
- IconPieChart2Stroked,
- IconPlus,
- IconMinus
- } 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,
- getRelativeTime
- } 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";
- 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 [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 [showApiScrollHint, setShowApiScrollHint] = useState(false);
- const [searchModalVisible, setSearchModalVisible] = useState(false);
- const [trendData, setTrendData] = useState({
- balance: [],
- usedQuota: [],
- requestCount: [],
- times: [],
- consumeQuota: [],
- tokens: [],
- rpm: [],
- tpm: []
- });
- // ========== Additional Refs for new cards ==========
- const announcementScrollRef = useRef(null);
- const faqScrollRef = useRef(null);
- const uptimeScrollRef = useRef(null);
- const uptimeTabScrollRefs = useRef({});
- // ========== Additional State for scroll hints ==========
- const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
- const [showFaqScrollHint, setShowFaqScrollHint] = useState(false);
- const [showUptimeScrollHint, setShowUptimeScrollHint] = useState(false);
- // ========== 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,
- },
- });
- // ========== 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 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);
- }, []);
- // ========== 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 checkCardScrollable = (ref, setHintFunction) => {
- if (ref.current) {
- const element = ref.current;
- const isScrollable = element.scrollHeight > element.clientHeight;
- const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 5;
- setHintFunction(isScrollable && !isAtBottom);
- }
- };
- const handleCardScroll = (ref, setHintFunction) => {
- checkCardScrollable(ref, setHintFunction);
- };
- // ========== Effects for scroll detection ==========
- useEffect(() => {
- const timer = setTimeout(() => {
- checkApiScrollable();
- checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
- checkCardScrollable(faqScrollRef, setShowFaqScrollHint);
- if (uptimeData.length === 1) {
- checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
- } else if (uptimeData.length > 1 && activeUptimeTab) {
- const activeTabRef = uptimeTabScrollRefs.current[activeUptimeTab];
- if (activeTabRef) {
- checkCardScrollable(activeTabRef, setShowUptimeScrollHint);
- }
- }
- }, 100);
- return () => clearTimeout(timer);
- }, [uptimeData, activeUptimeTab]);
- 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
- ]);
- // ========== 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">
- <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 ${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>
- <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>
- {hasApiInfoPanel && (
- <Card
- {...CARD_PROPS}
- className="bg-gray-50 border-0 !rounded-2xl"
- title={
- <div className={FLEX_CENTER_GAP2}>
- <Server size={16} />
- {t('API信息')}
- </div>
- }
- >
- <div className="card-content-container">
- <div
- ref={apiScrollRef}
- className="space-y-3 max-h-96 overflow-y-auto card-content-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-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-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>
- ))
- ) : (
- <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>
- )}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showApiScrollHint ? 1 : 0 }}
- />
- </div>
- </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 size="small" color="grey" 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>
- }
- >
- <div className="card-content-container">
- <div
- ref={announcementScrollRef}
- className="p-2 max-h-96 overflow-y-auto card-content-scroll"
- onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
- >
- {announcementData.length > 0 ? (
- <Timeline
- mode="alternate"
- dataSource={announcementData}
- />
- ) : (
- <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>
- )}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
- />
- </div>
- </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 }}
- >
- <div className="card-content-container">
- <div
- ref={faqScrollRef}
- className="p-2 max-h-96 overflow-y-auto card-content-scroll"
- onScroll={() => handleCardScroll(faqScrollRef, setShowFaqScrollHint)}
- >
- {faqData.length > 0 ? (
- <Collapse
- accordion
- expandIcon={<IconPlus />}
- collapseIcon={<IconMinus />}
- >
- {faqData.map((item, index) => (
- <Collapse.Panel
- key={index}
- header={item.question}
- itemKey={index.toString()}
- >
- <p>{item.answer}</p>
- </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>
- )}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showFaqScrollHint ? 1 : 0 }}
- />
- </div>
- </Card>
- )}
- {/* 服务可用性卡片 */}
- {uptimeEnabled && (
- <Card
- {...CARD_PROPS}
- className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
- title={
- <div className="flex items-center justify-between w-full gap-2">
- <div className="flex items-center gap-2">
- <Gauge size={16} />
- {t('服务可用性')}
- </div>
- <IconButton
- icon={<IconRefresh />}
- onClick={loadUptimeData}
- loading={uptimeLoading}
- size="small"
- theme="borderless"
- className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
- />
- </div>
- }
- bodyStyle={{ padding: 0 }}
- >
- {/* 内容区域 */}
- <div className="flex-1 relative">
- <Spin spinning={uptimeLoading}>
- {uptimeData.length > 0 ? (
- uptimeData.length === 1 ? (
- <div className="card-content-container">
- <div
- ref={uptimeScrollRef}
- className="p-2 max-h-[24rem] overflow-y-auto card-content-scroll"
- onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
- >
- {renderMonitorList(uptimeData[0].monitors)}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
- />
- </div>
- ) : (
- <Tabs
- type="card"
- collapsible
- activeKey={activeUptimeTab}
- onChange={setActiveUptimeTab}
- size="small"
- >
- {uptimeData.map((group, groupIdx) => {
- if (!uptimeTabScrollRefs.current[group.categoryName]) {
- uptimeTabScrollRefs.current[group.categoryName] = React.createRef();
- }
- const tabScrollRef = uptimeTabScrollRefs.current[group.categoryName];
- return (
- <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}
- >
- <div className="card-content-container">
- <div
- ref={tabScrollRef}
- className="p-2 max-h-[21.5rem] overflow-y-auto card-content-scroll"
- onScroll={() => handleCardScroll(tabScrollRef, setShowUptimeScrollHint)}
- >
- {renderMonitorList(group.monitors)}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: activeUptimeTab === group.categoryName ? showUptimeScrollHint ? 1 : 0 : 0 }}
- />
- </div>
- </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 mt-auto 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>
- )}
- </Spin>
- </div>
- );
- };
- export default Detail;
|