📚 refactor(dashboard): modularize dashboard page into reusable hooks and components
## Overview Refactored the monolithic dashboard page (~1200 lines) into a modular architecture following the project's global layout pattern. The main `Detail/index.js` is now simplified to match other page entry files like `Midjourney/index.js`. ## Changes Made ### 🏗️ Architecture Changes - **Before**: Single large file `pages/Detail/index.js` containing all dashboard logic - **After**: Modular structure with dedicated hooks, components, and helpers ### 📁 New Files Created - `hooks/dashboard/useDashboardData.js` - Core data management and API calls - `hooks/dashboard/useDashboardStats.js` - Statistics computation and memoization - `hooks/dashboard/useDashboardCharts.js` - Chart specifications and data processing - `constants/dashboard.constants.js` - UI config, time options, and chart defaults - `helpers/dashboard.js` - Utility functions for data processing and UI helpers - `components/dashboard/index.jsx` - Main dashboard component integrating all modules - `components/dashboard/modals/SearchModal.jsx` - Search modal component ### 🔧 Updated Files - `constants/index.js` - Added dashboard constants export - `helpers/index.js` - Added dashboard helpers export - `pages/Detail/index.js` - Simplified to minimal wrapper (~20 lines) ### 🐛 Bug Fixes - Fixed SearchModal DatePicker onChange to properly convert Date objects to timestamp strings - Added missing localStorage update for `data_export_default_time` persistence - Corrected data flow between search confirmation and chart updates - Ensured proper chart data refresh after search parameter changes ### ✨ Key Improvements - **Separation of Concerns**: Data, stats, and charts logic isolated into dedicated hooks - **Reusability**: Components and hooks can be easily reused across the application - **Maintainability**: Smaller, focused files easier to understand and modify - **Consistency**: Follows established project patterns for global folder organization - **Performance**: Proper memoization and callback optimization maintained ### 🎯 Functional Verification - ✅ All dashboard panels (model analysis, resource consumption, performance metrics) update correctly - ✅ Search functionality works with proper parameter validation - ✅ Chart data refreshes properly after search/filter operations - ✅ User interface remains identical to original implementation - ✅ All existing features preserved without regression ### 🔄 Data Flow ``` User Input → SearchModal → useDashboardData → API Call → useDashboardCharts → UI Update ``` ## Breaking Changes None. All existing functionality preserved. ## Migration Notes The refactored dashboard maintains 100% API compatibility and identical user experience while providing a cleaner, more maintainable codebase structure.
This commit is contained in:
74
web/src/components/common/charts/TrendChart.jsx
Normal file
74
web/src/components/common/charts/TrendChart.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
const TrendChart = ({
|
||||
data,
|
||||
color,
|
||||
width = 100,
|
||||
height = 40,
|
||||
config = { mode: 'desktop-browser' }
|
||||
}) => {
|
||||
const getTrendSpec = (data, color) => ({
|
||||
type: 'line',
|
||||
data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
|
||||
xField: 'x',
|
||||
yField: 'y',
|
||||
height: height,
|
||||
width: width,
|
||||
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'
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<VChart
|
||||
spec={getTrendSpec(data, color)}
|
||||
option={config}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendChart;
|
||||
107
web/src/components/dashboard/AnnouncementsPanel.jsx
Normal file
107
web/src/components/dashboard/AnnouncementsPanel.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const AnnouncementsPanel = ({
|
||||
announcementData,
|
||||
announcementLegendData,
|
||||
CARD_PROPS,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
||||
117
web/src/components/dashboard/ApiInfoPanel.jsx
Normal file
117
web/src/components/dashboard/ApiInfoPanel.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
|
||||
import { Server, Gauge, ExternalLink } from 'lucide-react';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const ApiInfoPanel = ({
|
||||
apiInfoData,
|
||||
handleCopyUrl,
|
||||
handleSpeedTest,
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<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) => (
|
||||
<React.Fragment key={api.id}>
|
||||
<div 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 />
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiInfoPanel;
|
||||
117
web/src/components/dashboard/ChartsPanel.jsx
Normal file
117
web/src/components/dashboard/ChartsPanel.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||
import { PieChart } from 'lucide-react';
|
||||
import {
|
||||
IconHistogram,
|
||||
IconPulse,
|
||||
IconPieChart2Stroked
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
const ChartsPanel = ({
|
||||
activeChartTab,
|
||||
setActiveChartTab,
|
||||
spec_line,
|
||||
spec_model_line,
|
||||
spec_pie,
|
||||
spec_rank_bar,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG,
|
||||
FLEX_CENTER_GAP2,
|
||||
hasApiInfoPanel,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartsPanel;
|
||||
61
web/src/components/dashboard/DashboardHeader.jsx
Normal file
61
web/src/components/dashboard/DashboardHeader.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const DashboardHeader = ({
|
||||
getGreeting,
|
||||
greetingVisible,
|
||||
showSearchModal,
|
||||
refresh,
|
||||
loading,
|
||||
t
|
||||
}) => {
|
||||
const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardHeader;
|
||||
81
web/src/components/dashboard/FaqPanel.jsx
Normal file
81
web/src/components/dashboard/FaqPanel.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const FaqPanel = ({
|
||||
faqData,
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqPanel;
|
||||
93
web/src/components/dashboard/StatsCards.jsx
Normal file
93
web/src/components/dashboard/StatsCards.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Card, Avatar, Skeleton } from '@douyinfe/semi-ui';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
const StatsCards = ({
|
||||
groupedStatsData,
|
||||
loading,
|
||||
getTrendSpec,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCards;
|
||||
136
web/src/components/dashboard/UptimePanel.jsx
Normal file
136
web/src/components/dashboard/UptimePanel.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Card, Button, Spin, Tabs, TabPane, Tag, Empty } from '@douyinfe/semi-ui';
|
||||
import { Gauge } from 'lucide-react';
|
||||
import { IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const UptimePanel = ({
|
||||
uptimeData,
|
||||
uptimeLoading,
|
||||
activeUptimeTab,
|
||||
setActiveUptimeTab,
|
||||
loadUptimeData,
|
||||
uptimeLegendData,
|
||||
renderMonitorList,
|
||||
CARD_PROPS,
|
||||
ILLUSTRATION_SIZE,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default UptimePanel;
|
||||
247
web/src/components/dashboard/index.jsx
Normal file
247
web/src/components/dashboard/index.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
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 } from 'react';
|
||||
import { getRelativeTime } from '../../helpers';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import StatsCards from './StatsCards';
|
||||
import ChartsPanel from './ChartsPanel';
|
||||
import ApiInfoPanel from './ApiInfoPanel';
|
||||
import AnnouncementsPanel from './AnnouncementsPanel';
|
||||
import FaqPanel from './FaqPanel';
|
||||
import UptimePanel from './UptimePanel';
|
||||
import SearchModal from './modals/SearchModal';
|
||||
|
||||
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
||||
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
|
||||
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
|
||||
|
||||
import {
|
||||
CHART_CONFIG,
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
ANNOUNCEMENT_LEGEND_DATA,
|
||||
UPTIME_STATUS_MAP
|
||||
} from '../../constants/dashboard.constants';
|
||||
import {
|
||||
getTrendSpec,
|
||||
handleCopyUrl,
|
||||
handleSpeedTest,
|
||||
getUptimeStatusColor,
|
||||
getUptimeStatusText,
|
||||
renderMonitorList
|
||||
} from '../../helpers/dashboard';
|
||||
|
||||
const Dashboard = () => {
|
||||
// ========== Context ==========
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// ========== 主要数据管理 ==========
|
||||
const dashboardData = useDashboardData(userState, userDispatch, statusState);
|
||||
|
||||
// ========== 图表管理 ==========
|
||||
const dashboardCharts = useDashboardCharts(
|
||||
dashboardData.dataExportDefaultTime,
|
||||
dashboardData.setTrendData,
|
||||
dashboardData.setConsumeQuota,
|
||||
dashboardData.setTimes,
|
||||
dashboardData.setConsumeTokens,
|
||||
dashboardData.setPieData,
|
||||
dashboardData.setLineData,
|
||||
dashboardData.setModelColors,
|
||||
dashboardData.t
|
||||
);
|
||||
|
||||
// ========== 统计数据 ==========
|
||||
const { groupedStatsData } = useDashboardStats(
|
||||
userState,
|
||||
dashboardData.consumeQuota,
|
||||
dashboardData.consumeTokens,
|
||||
dashboardData.times,
|
||||
dashboardData.trendData,
|
||||
dashboardData.performanceMetrics,
|
||||
dashboardData.navigate,
|
||||
dashboardData.t
|
||||
);
|
||||
|
||||
// ========== 数据处理 ==========
|
||||
const initChart = async () => {
|
||||
await dashboardData.loadQuotaData().then(data => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
});
|
||||
await dashboardData.loadUptimeData();
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const data = await dashboardData.refresh();
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchConfirm = async () => {
|
||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
||||
};
|
||||
|
||||
// ========== 数据准备 ==========
|
||||
const apiInfoData = statusState?.status?.api_info || [];
|
||||
const announcementData = (statusState?.status?.announcements || []).map(item => ({
|
||||
...item,
|
||||
time: getRelativeTime(item.publishDate)
|
||||
}));
|
||||
const faqData = statusState?.status?.faq || [];
|
||||
|
||||
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(([status, info]) => ({
|
||||
status: Number(status),
|
||||
color: info.color,
|
||||
label: dashboardData.t(info.label)
|
||||
}));
|
||||
|
||||
// ========== Effects ==========
|
||||
useEffect(() => {
|
||||
initChart();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<DashboardHeader
|
||||
getGreeting={dashboardData.getGreeting}
|
||||
greetingVisible={dashboardData.greetingVisible}
|
||||
showSearchModal={dashboardData.showSearchModal}
|
||||
refresh={handleRefresh}
|
||||
loading={dashboardData.loading}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
|
||||
<SearchModal
|
||||
searchModalVisible={dashboardData.searchModalVisible}
|
||||
handleSearchConfirm={handleSearchConfirm}
|
||||
handleCloseModal={dashboardData.handleCloseModal}
|
||||
isMobile={dashboardData.isMobile}
|
||||
isAdminUser={dashboardData.isAdminUser}
|
||||
inputs={dashboardData.inputs}
|
||||
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
|
||||
timeOptions={dashboardData.timeOptions}
|
||||
handleInputChange={dashboardData.handleInputChange}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
|
||||
<StatsCards
|
||||
groupedStatsData={groupedStatsData}
|
||||
loading={dashboardData.loading}
|
||||
getTrendSpec={getTrendSpec}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
/>
|
||||
|
||||
{/* API信息和图表面板 */}
|
||||
<div className="mb-4">
|
||||
<div className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
|
||||
<ChartsPanel
|
||||
activeChartTab={dashboardData.activeChartTab}
|
||||
setActiveChartTab={dashboardData.setActiveChartTab}
|
||||
spec_line={dashboardCharts.spec_line}
|
||||
spec_model_line={dashboardCharts.spec_model_line}
|
||||
spec_pie={dashboardCharts.spec_pie}
|
||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
|
||||
{dashboardData.hasApiInfoPanel && (
|
||||
<ApiInfoPanel
|
||||
apiInfoData={apiInfoData}
|
||||
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
|
||||
handleSpeedTest={handleSpeedTest}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统公告和常见问答卡片 */}
|
||||
{dashboardData.hasInfoPanels && (
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
{/* 公告卡片 */}
|
||||
{dashboardData.announcementsEnabled && (
|
||||
<AnnouncementsPanel
|
||||
announcementData={announcementData}
|
||||
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(item => ({
|
||||
...item,
|
||||
label: dashboardData.t(item.label)
|
||||
}))}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 常见问答卡片 */}
|
||||
{dashboardData.faqEnabled && (
|
||||
<FaqPanel
|
||||
faqData={faqData}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 服务可用性卡片 */}
|
||||
{dashboardData.uptimeEnabled && (
|
||||
<UptimePanel
|
||||
uptimeData={dashboardData.uptimeData}
|
||||
uptimeLoading={dashboardData.uptimeLoading}
|
||||
activeUptimeTab={dashboardData.activeUptimeTab}
|
||||
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
|
||||
loadUptimeData={dashboardData.loadUptimeData}
|
||||
uptimeLegendData={uptimeLegendData}
|
||||
renderMonitorList={(monitors) => renderMonitorList(
|
||||
monitors,
|
||||
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
||||
(status) => getUptimeStatusText(status, UPTIME_STATUS_MAP, dashboardData.t),
|
||||
dashboardData.t
|
||||
)}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
101
web/src/components/dashboard/modals/SearchModal.jsx
Normal file
101
web/src/components/dashboard/modals/SearchModal.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
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, { useRef } from 'react';
|
||||
import { Modal, Form } from '@douyinfe/semi-ui';
|
||||
|
||||
const SearchModal = ({
|
||||
searchModalVisible,
|
||||
handleSearchConfirm,
|
||||
handleCloseModal,
|
||||
isMobile,
|
||||
isAdminUser,
|
||||
inputs,
|
||||
dataExportDefaultTime,
|
||||
timeOptions,
|
||||
handleInputChange,
|
||||
t
|
||||
}) => {
|
||||
const formRef = useRef();
|
||||
|
||||
const FORM_FIELD_PROPS = {
|
||||
className: "w-full mb-2 !rounded-lg",
|
||||
};
|
||||
|
||||
const createFormField = (Component, props) => (
|
||||
<Component {...FORM_FIELD_PROPS} {...props} />
|
||||
);
|
||||
|
||||
const { start_timestamp, end_timestamp, username } = inputs;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchModal;
|
||||
Reference in New Issue
Block a user