|
@@ -1,1610 +0,0 @@
|
|
|
-/*
|
|
|
|
|
-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;
|
|
|