| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362 |
- 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
- } 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";
- // ========== Constants ==========
- let now = new Date();
- const isAdminUser = isAdmin();
- // ========== 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);
- // ========== 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);
- // ========== 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 || []);
- } else {
- showError(message);
- }
- } catch (err) {
- console.error(err);
- } finally {
- setUptimeLoading(false);
- }
- }, []);
- 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);
- checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
- }, 100);
- return () => clearTimeout(timer);
- }, [uptimeData]);
- 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]);
- // ========== 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 ${!statusState?.status?.self_use_mode_enabled ? 'lg:grid-cols-4' : ''}`}>
- <Card
- {...CARD_PROPS}
- className={`shadow-sm !rounded-2xl ${!statusState?.status?.self_use_mode_enabled ? '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>
- {!statusState?.status?.self_use_mode_enabled && (
- <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={{ width: 80, height: 80 }} />}
- darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
- title={t('暂无API信息')}
- description={t('请联系管理员在系统设置中配置API信息')}
- style={{ padding: '12px' }}
- />
- </div>
- )}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showApiScrollHint ? 1 : 0 }}
- />
- </div>
- </Card>
- )}
- </div>
- </div>
- {/* 系统公告和常见问答卡片 */}
- {!statusState?.status?.self_use_mode_enabled && (
- <div className="mb-4">
- <div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
- {/* 公告卡片 */}
- <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={{ width: 80, height: 80 }} />}
- darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
- title={t('暂无系统公告')}
- description={t('请联系管理员在系统设置中配置公告信息')}
- style={{ padding: '12px' }}
- />
- </div>
- )}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showAnnouncementScrollHint ? 1 : 0 }}
- />
- </div>
- </Card>
- {/* 常见问答卡片 */}
- <Card
- {...CARD_PROPS}
- className="shadow-sm !rounded-2xl lg:col-span-1"
- title={
- <div className={FLEX_CENTER_GAP2}>
- <HelpCircle size={16} />
- {t('常见问答')}
- </div>
- }
- >
- <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.title}
- itemKey={index.toString()}
- >
- <p>{item.content}</p>
- </Collapse.Panel>
- ))}
- </Collapse>
- ) : (
- <div className="flex justify-center items-center py-8">
- <Empty
- image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
- darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
- title={t('暂无常见问答')}
- description={t('请联系管理员在系统设置中配置常见问答')}
- style={{ padding: '12px' }}
- />
- </div>
- )}
- </div>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showFaqScrollHint ? 1 : 0 }}
- />
- </div>
- </Card>
- {/* 服务可用性卡片 */}
- <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>
- <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>
- }
- footer={uptimeData.length > 0 ? (
- <Card
- bordered={false}
- className="!rounded-2xl backdrop-blur !shadow-none"
- >
- <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>
- </Card>
- ) : null}
- footerStyle={uptimeData.length > 0 ? { padding: '0px' } : undefined}
- >
- <div className="card-content-container">
- <Spin spinning={uptimeLoading}>
- <div
- ref={uptimeScrollRef}
- className="p-2 max-h-80 overflow-y-auto card-content-scroll"
- onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
- >
- {uptimeData.length > 0 ? (
- uptimeData.map((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>
- ))
- ) : (
- <div className="flex justify-center items-center py-8">
- <Empty
- image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
- darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
- title={t('暂无监控数据')}
- description={t('请联系管理员在系统设置中配置Uptime')}
- style={{ padding: '12px' }}
- />
- </div>
- )}
- </div>
- </Spin>
- <div
- className="card-content-fade-indicator"
- style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
- />
- </div>
- </Card>
- </div>
- </div>
- )}
- </Spin>
- </div>
- );
- };
- export default Detail;
|