feat: persist system settings and refine admin layouts

This commit is contained in:
rayd1o
2026-03-25 02:57:58 +08:00
parent 81a0ca5e7a
commit ef0fefdfc7
19 changed files with 2091 additions and 1231 deletions

View File

@@ -6,6 +6,7 @@ import Users from './pages/Users/Users'
import DataSources from './pages/DataSources/DataSources'
import DataList from './pages/DataList/DataList'
import Earth from './pages/Earth/Earth'
import Settings from './pages/Settings/Settings'
function App() {
const { token } = useAuthStore()
@@ -23,9 +24,10 @@ function App() {
<Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default App
export default App

View File

@@ -1,5 +1,5 @@
import { ReactNode, useState } from 'react'
import { Layout, Menu, Typography, Button } from 'antd'
import { Layout, Menu, Typography, Button, Space } from 'antd'
import {
DashboardOutlined,
DatabaseOutlined,
@@ -12,7 +12,7 @@ import {
import { Link, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
const { Header, Sider, Content } = Layout
const { Sider, Content } = Layout
const { Text } = Typography
interface AppLayoutProps {
@@ -23,6 +23,7 @@ function AppLayout({ children }: AppLayoutProps) {
const location = useLocation()
const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false)
const showBanner = true
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/"></Link> },
@@ -34,43 +35,56 @@ function AppLayout({ children }: AppLayoutProps) {
return (
<Layout className="dashboard-layout">
<Sider
width={240}
collapsedWidth={80}
collapsible
collapsed={collapsed}
<Sider
width={208}
collapsedWidth={72}
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
className="dashboard-sider"
>
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{collapsed ? (
<Text strong style={{ color: 'white', fontSize: 20 }}>🌏</Text>
) : (
<Text strong style={{ color: 'white', fontSize: 18 }}></Text>
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
/>
</Sider>
<Layout>
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px' }}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ fontSize: 16 }}
/>
<Text strong>, {user?.username}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button type="link" danger onClick={logout}>退</Button>
<div className="dashboard-sider-inner">
<div>
<div className={`dashboard-brand ${collapsed ? 'dashboard-brand--collapsed' : ''}`} onClick={collapsed ? () => setCollapsed(false) : undefined}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={(event) => {
event.stopPropagation()
setCollapsed(!collapsed)
}}
className={`dashboard-sider-toggle ${collapsed ? 'dashboard-sider-toggle--collapsed' : ''}`}
/>
{!collapsed ? (
<Text strong style={{ color: 'white', fontSize: 18 }}></Text>
) : null}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
/>
</div>
</Header>
<Content className="dashboard-content" style={{ padding: 24, minHeight: '100%', overflow: 'auto' }}>
{children}
{showBanner && !collapsed ? (
<div className="dashboard-sider-banner">
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div>
<Text className="dashboard-sider-banner-label"></Text>
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
</div>
<Button type="primary" danger ghost block onClick={logout}>
退
</Button>
</Space>
</div>
) : null}
</div>
</Sider>
<Layout style={{ minWidth: 0, minHeight: 0, height: '100%' }}>
<Content className="dashboard-content" style={{ padding: 24, minHeight: 0, height: '100%' }}>
<div className="dashboard-content-inner">{children}</div>
</Content>
</Layout>
</Layout>

View File

@@ -31,29 +31,247 @@ body {
}
.dashboard-layout {
min-height: 100vh;
height: 100vh;
}
.dashboard-layout .ant-layout,
.dashboard-layout .ant-layout-content {
min-width: 0;
min-height: 0;
}
.dashboard-layout > .ant-layout {
height: 100%;
}
.dashboard-sider {
background: #001529 !important;
}
.ant-layout-sider-trigger {
display: none !important;
.dashboard-sider-inner {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.dashboard-header {
background: white;
padding: 0 24px;
.dashboard-brand {
position: relative;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
padding-left: 12px;
padding-right: 40px;
}
.dashboard-brand .ant-typography {
margin-right: auto;
padding-left: 24px;
transform: none;
}
.dashboard-brand--collapsed {
cursor: pointer;
}
.dashboard-sider-toggle {
position: absolute;
top: 50%;
right: 10px;
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-50%);
color: rgba(255, 255, 255, 0.88) !important;
}
.dashboard-sider-toggle--collapsed {
left: 50%;
right: auto;
width: 32px;
transform: translate(-50%, -50%);
justify-content: center;
}
.dashboard-sider-banner {
margin: 12px;
padding: 14px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.dashboard-sider-banner-label {
display: block;
margin-bottom: 4px;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
}
.dashboard-sider-banner-value {
display: block;
color: white !important;
font-size: 14px;
}
.dashboard-sider-logout {
width: 100%;
color: #ff7875 !important;
}
.ant-layout-sider-trigger {
display: none !important;
}
.dashboard-content {
padding: 24px;
background: #f0f2f5;
min-height: calc(100vh - 64px);
height: 100%;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dashboard-content-inner {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.dashboard-content-inner > * {
min-width: 0;
min-height: 0;
}
.page-shell {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-shell__header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-shell__body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-shell__body > * {
min-width: 0;
min-height: 0;
}
.data-source-tabs-shell,
.data-source-tabs,
.data-source-tabs .ant-tabs-content-holder,
.data-source-tabs .ant-tabs-content,
.data-source-tabs .ant-tabs-tabpane {
min-width: 0;
min-height: 0;
height: 100%;
}
.data-source-tabs-shell {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.data-source-tabs {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.data-source-tabs .ant-tabs-nav {
flex: 0 0 auto;
margin-bottom: 12px;
}
.data-source-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.data-source-tabs .ant-tabs-content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.data-source-tabs .ant-tabs-tabpane {
display: flex;
flex-direction: column;
overflow: hidden;
}
.data-source-custom-tab {
gap: 12px;
}
.data-source-custom-toolbar {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
}
.data-source-table-region {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container,
.data-source-table-region .ant-table,
.data-source-table-region .ant-table-container,
.data-source-table-region .ant-table-body {
height: 100%;
min-height: 0;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container {
display: flex;
flex-direction: column;
}
.data-source-empty-state {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 12px;
}
.stat-card {
@@ -88,37 +306,6 @@ body {
color: #ff4d4f;
}
/* Table column resize */
.ant-table-wrapper .ant-table-thead > tr > th {
position: relative;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
background: transparent;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
.resize-handle::before {
content: '';
width: 2px;
height: 20px;
background: #d9d9d9;
border-radius: 1px;
}
.resize-handle:hover::before {
background: #1890ff;
}
/* Table cell fixed width */
.ant-table-wrapper .ant-table-tbody > tr > td {
max-width: 0;
@@ -126,3 +313,405 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
}
.ant-table-wrapper {
width: 100%;
}
.ant-table-wrapper .ant-table-container,
.ant-table-wrapper .ant-table-content,
.ant-table-wrapper .ant-table-body {
overflow-x: auto !important;
}
.table-scroll-region {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.table-scroll-region .ant-table-wrapper {
width: 100%;
min-width: 0;
}
.table-scroll-region .ant-table {
min-width: 100%;
}
.data-list-workspace {
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.data-list-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.data-list-controls-shell {
flex: 1 1 auto;
min-height: 0;
}
.data-list-split-layout {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(280px, 0.95fr) 12px minmax(0, 1fr);
gap: 0;
}
.data-list-summary-card,
.data-list-table-shell {
min-width: 0;
min-height: 0;
}
.data-list-summary-card--panel,
.data-list-summary-card--panel .ant-card-body {
height: 100%;
}
.data-list-summary-card--panel .ant-card-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.data-list-summary-card .ant-card-head,
.data-list-table-shell .ant-card-head {
padding-inline: 16px;
}
.data-list-summary-card .ant-card-body {
overflow: auto;
}
.data-list-right-column {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.data-list-summary-treemap {
min-height: 100%;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: minmax(56px, 1fr);
grid-auto-flow: dense;
gap: 10px;
}
.data-list-treemap-tile {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.55);
color: #0f172a;
overflow: hidden;
}
.data-list-treemap-tile--ocean {
background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%);
}
.data-list-treemap-tile--sky {
background: linear-gradient(135deg, #e0f2fe 0%, #7dd3fc 100%);
}
.data-list-treemap-tile--mint {
background: linear-gradient(135deg, #dcfce7 0%, #86efac 100%);
}
.data-list-treemap-tile--amber {
background: linear-gradient(135deg, #fef3c7 0%, #fcd34d 100%);
}
.data-list-treemap-tile--rose {
background: linear-gradient(135deg, #ffe4e6 0%, #fda4af 100%);
}
.data-list-treemap-tile--violet {
background: linear-gradient(135deg, #ede9fe 0%, #c4b5fd 100%);
}
.data-list-treemap-tile--slate {
background: linear-gradient(135deg, #e2e8f0 0%, #94a3b8 100%);
}
.data-list-treemap-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.data-list-treemap-label {
min-width: 0;
font-size: clamp(11px, 0.75vw, 13px);
line-height: 1.2;
color: rgba(15, 23, 42, 0.78);
}
.data-list-treemap-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.data-list-summary-tile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
color: #0f172a;
flex: 0 0 auto;
}
.data-list-summary-tile-value {
font-size: clamp(12px, 1vw, 16px);
line-height: 1.1;
color: #0f172a;
}
.data-list-treemap-meta {
color: rgba(15, 23, 42, 0.72) !important;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar {
width: 10px;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.8);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-track {
background: transparent;
}
.data-list-filter-grid {
min-width: 0;
display: flex;
flex-wrap: nowrap;
gap: 10px;
align-items: center;
}
.data-list-filter-grid--balanced > * {
flex: 1 1 0;
min-width: 0;
}
.data-list-filter-grid--header {
padding-bottom: 4px;
}
.data-list-table-shell {
min-height: 0;
display: flex;
flex-direction: column;
}
.data-list-table-shell .ant-card-body {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.data-list-table-header {
padding: 12px 14px 0 14px;
flex: 0 0 auto;
}
.data-list-table-header--with-filters {
display: flex;
flex-direction: column;
gap: 10px;
}
.data-list-table-header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.data-list-table-region {
flex: 1 1 auto;
min-height: 0;
overflow: visible;
}
.data-list-table-region .ant-table-wrapper,
.data-list-table-region .ant-spin-nested-loading,
.data-list-table-region .ant-spin-container {
height: 100%;
min-height: 0;
}
.data-list-table-region .ant-table-wrapper {
display: flex;
flex-direction: column;
}
.data-list-table-region .ant-spin-nested-loading,
.data-list-table-region .ant-spin-container {
display: flex;
flex-direction: column;
}
.data-list-table-region .ant-table {
flex: 1 1 auto;
}
.data-list-table-region .ant-table-pagination {
flex: 0 0 auto;
margin: 12px 0 0;
}
.data-list-resize-handle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
touch-action: none;
}
.data-list-resize-handle::before {
content: '';
display: block;
border-radius: 999px;
background: #d0d7e2;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.data-list-resize-handle:hover::before {
background: #8fb4ff;
}
.data-list-resize-handle--vertical {
cursor: col-resize;
}
.data-list-resize-handle--vertical::before {
width: 4px;
height: 56px;
}
.data-list-resize-handle--horizontal {
cursor: row-resize;
}
.data-list-resize-handle--horizontal::before {
width: 56px;
height: 4px;
}
@media (min-width: 1201px) and (orientation: landscape) {
.data-list-summary-treemap {
grid-auto-rows: minmax(48px, 1fr);
}
.data-list-treemap-tile {
padding: 10px 12px;
}
.data-list-summary-tile-value {
font-size: 15px;
}
}
@media (max-width: 1200px) {
.data-list-summary-treemap {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.data-list-split-layout {
grid-template-columns: minmax(240px, 0.9fr) 12px minmax(0, 1fr);
}
}
@media (max-width: 992px) {
.dashboard-content {
padding: 12px;
}
.data-list-workspace {
gap: 10px;
}
.data-list-topbar {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.data-list-split-layout {
grid-template-columns: 1fr;
gap: 10px;
height: auto;
}
.data-list-right-column {
grid-template-rows: auto auto;
gap: 10px;
height: auto;
}
.data-list-summary-treemap {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(88px, 1fr);
}
.data-list-filter-grid {
flex-wrap: wrap;
}
.data-list-filter-grid--balanced > * {
flex: 1 1 180px;
min-width: 160px;
}
}
@media (max-width: 640px) {
.data-list-summary-treemap {
grid-template-columns: 1fr;
}
.data-list-filter-grid {
flex-wrap: wrap;
}
.data-list-filter-grid--balanced > * {
flex-basis: 100%;
min-width: 100%;
}
}

View File

@@ -174,7 +174,9 @@ function Alerts() {
title="告警列表"
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}></Button>}
>
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
<div className="table-scroll-region">
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
</div>
</Card>
<Modal

View File

@@ -1,20 +1,16 @@
import { useEffect, useState } from 'react'
import { Layout, Menu, Card, Row, Col, Statistic, Typography, Button, Tag, Spin } from 'antd'
import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space } from 'antd'
import {
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
SettingOutlined,
BarChartOutlined,
AlertOutlined,
WifiOutlined,
DisconnectOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
const { Header, Sider, Content } = Layout
const { Title, Text } = Typography
interface Stats {
@@ -31,7 +27,7 @@ interface Stats {
}
function Dashboard() {
const { user, logout, token, clearAuth } = useAuthStore()
const { token, clearAuth } = useAuthStore()
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [wsConnected, setWsConnected] = useState(false)
@@ -63,7 +59,7 @@ function Dashboard() {
}
fetchStats()
}, [token])
}, [token, clearAuth])
useEffect(() => {
if (!token) return
@@ -112,28 +108,10 @@ function Dashboard() {
}
}, [token])
const handleLogout = () => {
logout()
window.location.href = '/'
}
const handleClearAuth = () => {
clearAuth()
window.location.href = '/'
}
const handleRetry = () => {
window.location.reload()
}
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/"></Link> },
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources"></Link> },
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data"></Link> },
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users"></Link> },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
]
if (loading && !stats) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
@@ -143,81 +121,78 @@ function Dashboard() {
}
return (
<Layout className="dashboard-layout">
<Sider width={240} className="dashboard-sider">
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title level={4} style={{ color: 'white', margin: 0 }}></Title>
</div>
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/']} items={menuItems} />
</Sider>
<Layout>
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text strong>, {user?.username}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<AppLayout>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"></Text>
</div>
<Space wrap>
{wsConnected ? (
<Tag icon={<WifiOutlined />} color="success"></Tag>
) : (
<Tag icon={<DisconnectOutlined />} color="default">线</Tag>
)}
<Button type="link" danger onClick={handleLogout}>退</Button>
<Button type="link" onClick={handleClearAuth}></Button>
<Button type="link" icon={<ReloadOutlined />} onClick={handleRetry}></Button>
</div>
</Header>
<Content className="dashboard-content">
{error && (
<Card style={{ marginBottom: 16, borderColor: '#ff4d4f' }}>
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
<Button type="default" icon={<ReloadOutlined />} onClick={handleRetry}></Button>
</Space>
</div>
{error && (
<Card style={{ borderColor: '#ff4d4f' }}>
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
</Card>
)}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
</Card>
)}
<Row gutter={[16, 16]}>
<Col span={6}>
<Card>
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={8}>
<Card>
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="警告" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
</Card>
</Col>
</Row>
{stats?.last_updated && (
<div style={{ marginTop: 16, textAlign: 'center', color: '#8c8c8c' }}>
: {new Date(stats.last_updated).toLocaleString('zh-CN')}
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}></Tag>}
</div>
)}
</Content>
</Layout>
</Layout>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card>
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card>
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="警" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
</Card>
</Col>
</Row>
{stats?.last_updated && (
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
: {new Date(stats.last_updated).toLocaleString('zh-CN')}
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}></Tag>}
</div>
)}
</div>
</AppLayout>
)
}

View File

@@ -1,16 +1,19 @@
import { useEffect, useState, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Table, Tag, Space, Card, Row, Col, Select, Input, Button,
Statistic, Modal, Descriptions, Spin, Empty, Tooltip
Table, Tag, Space, Card, Select, Input, Button,
Modal, Descriptions, Spin, Empty, Tooltip, Typography, Grid
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
AppstoreOutlined, EyeOutlined, SearchOutlined
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
const { Title, Text } = Typography
const { useBreakpoint } = Grid
interface CollectedData {
id: number
source: string
@@ -38,6 +41,21 @@ interface Summary {
}
function DataList() {
const screens = useBreakpoint()
const isCompact = !screens.lg
const topbarRef = useRef<HTMLDivElement | null>(null)
const workspaceRef = useRef<HTMLDivElement | null>(null)
const mainAreaRef = useRef<HTMLDivElement | null>(null)
const rightColumnRef = useRef<HTMLDivElement | null>(null)
const tableHeaderRef = useRef<HTMLDivElement | null>(null)
const hasCustomLeftWidthRef = useRef(false)
const [mainAreaWidth, setMainAreaWidth] = useState(0)
const [mainAreaHeight, setMainAreaHeight] = useState(0)
const [rightColumnHeight, setRightColumnHeight] = useState(0)
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
const [data, setData] = useState<CollectedData[]>([])
const [loading, setLoading] = useState(false)
const [summary, setSummary] = useState<Summary | null>(null)
@@ -55,6 +73,73 @@ function DataList() {
const [detailData, setDetailData] = useState<CollectedData | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
useEffect(() => {
const updateLayout = () => {
setMainAreaWidth(mainAreaRef.current?.offsetWidth || 0)
setMainAreaHeight(mainAreaRef.current?.offsetHeight || 0)
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
}
updateLayout()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateLayout)
if (workspaceRef.current) observer.observe(workspaceRef.current)
if (topbarRef.current) observer.observe(topbarRef.current)
if (mainAreaRef.current) observer.observe(mainAreaRef.current)
if (rightColumnRef.current) observer.observe(rightColumnRef.current)
if (tableHeaderRef.current) observer.observe(tableHeaderRef.current)
return () => observer.disconnect()
}, [isCompact])
useEffect(() => {
if (isCompact || mainAreaWidth === 0) {
return
}
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft))
setLeftPanelWidth((current) => {
if (!hasCustomLeftWidthRef.current) {
return preferredLeft
}
return Math.max(minLeft, Math.min(current, maxLeft))
})
}, [isCompact, mainAreaWidth])
const beginHorizontalResize = (event: React.MouseEvent<HTMLDivElement>) => {
if (isCompact) return
event.preventDefault()
hasCustomLeftWidthRef.current = true
const startX = event.clientX
const startWidth = leftPanelWidth
const containerWidth = mainAreaRef.current?.offsetWidth || 0
const onMove = (moveEvent: MouseEvent) => {
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, containerWidth - minRight - 12)
const nextWidth = startWidth + moveEvent.clientX - startX
setLeftPanelWidth(Math.max(minLeft, Math.min(nextWidth, maxLeft)))
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const fetchData = async () => {
setLoading(true)
try {
@@ -115,6 +200,15 @@ function DataList() {
fetchData()
}
const handleReset = () => {
setSourceFilter(undefined)
setTypeFilter(undefined)
setCountryFilter(undefined)
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
}
const handleViewDetail = async (id: number) => {
setDetailVisible(true)
setDetailLoading(true)
@@ -130,102 +224,115 @@ function DataList() {
const getSourceIcon = (source: string) => {
const iconMap: Record<string, React.ReactNode> = {
'top500': <CloudServerOutlined />,
'huggingface_models': <AppstoreOutlined />,
'huggingface_datasets': <DatabaseOutlined />,
'huggingface_spaces': <AppstoreOutlined />,
'telegeography_cables': <GlobalOutlined />,
'epoch_ai_gpu': <CloudServerOutlined />,
top500: <CloudServerOutlined />,
huggingface_models: <AppstoreOutlined />,
huggingface_datasets: <DatabaseOutlined />,
huggingface_spaces: <AppstoreOutlined />,
telegeography_cables: <GlobalOutlined />,
epoch_ai_gpu: <CloudServerOutlined />,
}
return iconMap[source] || <DatabaseOutlined />
}
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
'supercomputer': 'red',
'model': 'blue',
'dataset': 'green',
'space': 'purple',
'submarine_cable': 'cyan',
'gpu_cluster': 'orange',
'ixp': 'magenta',
'network': 'gold',
'facility': 'lime',
supercomputer: 'red',
model: 'blue',
dataset: 'green',
space: 'purple',
submarine_cable: 'cyan',
gpu_cluster: 'orange',
ixp: 'magenta',
network: 'gold',
facility: 'lime',
}
return colors[type] || 'default'
}
const [columnsWidth, setColumnsWidth] = useState<Record<string, number>>({
id: 60,
name: 300,
source: 150,
data_type: 100,
country: 100,
value: 100,
collected_at: 160,
action: 80,
})
const activeFilterCount = useMemo(
() => [sourceFilter, typeFilter, countryFilter, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, countryFilter, searchText]
)
const resizeRef = useRef<{ startX: number; startWidth: number; key: string } | null>(null)
const summaryItems = useMemo(() => {
const items = [
{ key: 'total', label: '总记录', value: summary?.total_records || 0, icon: <DatabaseOutlined /> },
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: <FilterOutlined /> },
{ key: 'sources', label: '数据源数', value: sources.length, icon: <DatabaseOutlined /> },
]
const handleResizeStart = (key: string) => (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
resizeRef.current = {
startX: e.clientX,
startWidth: columnsWidth[key],
key,
for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
items.push({
key: item.source,
label: item.source,
value: item.count,
icon: getSourceIcon(item.source),
})
}
document.addEventListener('mousemove', handleResizeMove)
document.addEventListener('mouseup', handleResizeEnd)
}
const handleResizeMove = (e: MouseEvent) => {
if (!resizeRef.current) return
const diff = e.clientX - resizeRef.current.startX
const newWidth = Math.max(50, resizeRef.current.startWidth + diff)
setColumnsWidth((prev) => ({
...prev,
[resizeRef.current!.key]: newWidth,
}))
}
return items
}, [summary, total, activeFilterCount, isCompact, sources.length])
const handleResizeEnd = () => {
resizeRef.current = null
document.removeEventListener('mousemove', handleResizeMove)
document.removeEventListener('mouseup', handleResizeEnd)
}
const treemapColumns = useMemo(() => {
if (isCompact) return 1
if (leftPanelWidth < 360) return 2
if (leftPanelWidth < 520) return 3
return 4
}, [isCompact, leftPanelWidth])
const treemapRowHeight = useMemo(() => {
if (isCompact) return 88
if (leftPanelWidth < 360) return 44
if (leftPanelWidth < 520) return 48
return 56
}, [isCompact, leftPanelWidth])
const treemapItems = useMemo(() => {
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
const allowTallTiles = !isCompact && leftPanelWidth >= 520
return summaryItems.map((item, index) => {
const ratio = item.value / maxValue
let colSpan = 1
let rowSpan = 1
if (allowTallTiles && index === 0) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowTallTiles && ratio >= 0.7) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowTallTiles && ratio >= 0.35) {
rowSpan = 2
}
return {
...item,
colSpan,
rowSpan,
tone: palette[index % palette.length],
}
})
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
const pageHeight = '100%'
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
const compactTableHeight = mainAreaHeight - tableHeaderHeight - 156
const tableHeight = Math.max(180, isCompact ? compactTableHeight : desktopTableHeight)
const splitLayoutStyle = isCompact
? undefined
: { gridTemplateColumns: `${leftPanelWidth}px 12px minmax(0, 1fr)` }
const columns: ColumnsType<CollectedData> = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span>ID</span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('id')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
dataIndex: 'id',
key: 'id',
width: columnsWidth.id,
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('name')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '名称',
dataIndex: 'name',
key: 'name',
width: columnsWidth.name,
width: 280,
ellipsis: true,
render: (name: string, record: CollectedData) => (
<Tooltip title={name}>
@@ -236,101 +343,40 @@ function DataList() {
),
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('source')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '数据源',
dataIndex: 'source',
key: 'source',
width: columnsWidth.source,
render: (source: string) => (
<Tag icon={getSourceIcon(source)}>{source}</Tag>
),
width: 170,
render: (source: string) => <Tag icon={getSourceIcon(source)}>{source}</Tag>,
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('data_type')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '类型',
dataIndex: 'data_type',
key: 'data_type',
width: columnsWidth.data_type,
render: (type: string) => (
<Tag color={getTypeColor(type)}>{type}</Tag>
),
width: 120,
render: (type: string) => <Tag color={getTypeColor(type)}>{type}</Tag>,
},
{ title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true },
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span>/</span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('country')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
dataIndex: 'country',
key: 'country',
width: columnsWidth.country,
ellipsis: true,
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('value')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '数值',
dataIndex: 'value',
key: 'value',
width: columnsWidth.value,
render: (value: string | null, record: CollectedData) => (
value ? `${value} ${record.unit || ''}` : '-'
),
width: 140,
render: (value: string | null, record: CollectedData) => (value ? `${value} ${record.unit || ''}` : '-'),
},
{
title: () => (
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
<span></span>
<div
className="resize-handle"
onMouseDown={handleResizeStart('collected_at')}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
title: '采集时间',
dataIndex: 'collected_at',
key: 'collected_at',
width: columnsWidth.collected_at,
width: 180,
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: columnsWidth.action,
width: 96,
render: (_: unknown, record: CollectedData) => (
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
</Button>
),
@@ -339,93 +385,160 @@ function DataList() {
return (
<AppLayout>
<h2></h2>
<div ref={workspaceRef} className="data-list-workspace" style={{ height: pageHeight }}>
<div ref={topbarRef} className="data-list-topbar">
<div>
<Title level={4} style={{ margin: 0 }}></Title>
</div>
<Space size={8} wrap>
<Tag color="blue" style={{ marginInlineEnd: 0 }}>
{total.toLocaleString()}
</Tag>
<Tag color="default" style={{ marginInlineEnd: 0 }}>
{activeFilterCount}
</Tag>
</Space>
</div>
{/* Summary Cards */}
{summary && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="总记录数"
value={summary.total_records}
prefix={<DatabaseOutlined />}
/>
<div ref={mainAreaRef} className="data-list-controls-shell">
<div className="data-list-split-layout" style={splitLayoutStyle}>
<Card
className="data-list-summary-card data-list-summary-card--panel"
title="数据概览"
size="small"
bodyStyle={{ padding: isCompact ? 12 : 16 }}
>
<div
className="data-list-summary-treemap"
style={{
gridTemplateColumns: `repeat(${treemapColumns}, minmax(0, 1fr))`,
gridAutoRows: `minmax(${treemapRowHeight}px, 1fr)`,
}}
>
{treemapItems.map((item) => (
<div
key={item.key}
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
style={{
gridColumn: `span ${item.colSpan}`,
gridRow: `span ${item.rowSpan}`,
}}
>
<div className="data-list-treemap-head">
<span className="data-list-summary-tile-icon">{item.icon}</span>
<Text className="data-list-treemap-label">{item.label}</Text>
</div>
<div className="data-list-treemap-body">
<Text strong className="data-list-summary-tile-value">
{item.value.toLocaleString()}
</Text>
</div>
</div>
))}
</div>
</Card>
</Col>
{summary.source_totals.slice(0, 4).map((item) => (
<Col span={6} key={item.source}>
<Card>
<Statistic
title={item.source}
value={item.count}
prefix={getSourceIcon(item.source)}
/>
{!isCompact && (
<div
className="data-list-resize-handle data-list-resize-handle--vertical"
onMouseDown={beginHorizontalResize}
role="separator"
aria-orientation="vertical"
aria-label="调整左右分栏宽度"
/>
)}
<div ref={rightColumnRef} className="data-list-right-column">
<Card className="data-list-table-shell" bodyStyle={{ padding: 0 }}>
<div ref={tableHeaderRef} className="data-list-table-header data-list-table-header--with-filters">
<div className="data-list-table-header-main">
<Space size={8} wrap>
<Text strong></Text>
<Text type="secondary"> {total.toLocaleString()} </Text>
</Space>
<Space size={8} wrap>
<Button size="small" onClick={handleReset}></Button>
<Button size="small" icon={<ReloadOutlined />} onClick={fetchData}></Button>
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</div>
<div className="data-list-filter-grid data-list-filter-grid--balanced data-list-filter-grid--header">
<Select
size="middle"
placeholder="数据源"
allowClear
value={sourceFilter}
onChange={(value) => {
setSourceFilter(value)
setPage(1)
}}
options={sources.map((source) => ({ label: source, value: source }))}
style={{ width: '100%' }}
/>
<Select
size="middle"
placeholder="数据类型"
allowClear
value={typeFilter}
onChange={(value) => {
setTypeFilter(value)
setPage(1)
}}
options={types.map((type) => ({ label: type, value: type }))}
style={{ width: '100%' }}
/>
<Select
size="middle"
placeholder="国家"
allowClear
value={countryFilter}
onChange={(value) => {
setCountryFilter(value)
setPage(1)
}}
options={countries.map((country) => ({ label: country, value: country }))}
style={{ width: '100%' }}
/>
<Input
size="middle"
placeholder="搜索名称"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
onPressEnter={handleSearch}
/>
</div>
</div>
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
virtual
scroll={{ x: 'max-content', y: tableHeight }}
tableLayout="fixed"
size={isCompact ? 'small' : 'middle'}
pagination={{
current: page,
pageSize,
total,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
showSizeChanger: true,
showTotal: (count) => `${count}`,
}}
/>
</div>
</Card>
</Col>
))}
</Row>
)}
</div>
</div>
</div>
</div>
{/* Filters */}
<Card style={{ marginBottom: 16 }}>
<Space wrap>
<Select
placeholder="数据源"
allowClear
style={{ width: 180 }}
value={sourceFilter}
onChange={(v) => { setSourceFilter(v); setPage(1); }}
options={sources.map(s => ({ label: s, value: s }))}
/>
<Select
placeholder="数据类型"
allowClear
style={{ width: 150 }}
value={typeFilter}
onChange={(v) => { setTypeFilter(v); setPage(1); }}
options={types.map(t => ({ label: t, value: t }))}
/>
<Select
placeholder="国家"
allowClear
style={{ width: 150 }}
value={countryFilter}
onChange={(v) => { setCountryFilter(v); setPage(1); }}
options={countries.map(c => ({ label: c, value: c }))}
/>
<Input
placeholder="搜索名称"
style={{ width: 200 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onPressEnter={handleSearch}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</Card>
{/* Data Table */}
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
/>
{/* Detail Modal */}
<Modal
title="数据详情"
open={detailVisible}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Table, Tag, Space, message, Button, Form, Input, Select,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
@@ -67,6 +67,10 @@ function DataSources() {
const [recordCount, setRecordCount] = useState<number>(0)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<any>(null)
const builtinTableRegionRef = useRef<HTMLDivElement | null>(null)
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
const [customTableHeight, setCustomTableHeight] = useState(360)
const [form] = Form.useForm()
const fetchData = async () => {
@@ -91,6 +95,28 @@ function DataSources() {
fetchData()
}, [])
useEffect(() => {
const updateHeights = () => {
const builtinRegionHeight = builtinTableRegionRef.current?.offsetHeight || 0
const customRegionHeight = customTableRegionRef.current?.offsetHeight || 0
setBuiltinTableHeight(Math.max(220, builtinRegionHeight - 56))
setCustomTableHeight(Math.max(220, customRegionHeight - 56))
}
updateHeights()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateHeights)
if (builtinTableRegionRef.current) observer.observe(builtinTableRegionRef.current)
if (customTableRegionRef.current) observer.observe(customTableRegionRef.current)
return () => observer.disconnect()
}, [activeTab, builtInSources.length, customSources.length])
useEffect(() => {
const runningSources = builtInSources.filter(s => s.is_running)
if (runningSources.length === 0) return
@@ -440,16 +466,21 @@ function DataSources() {
key: 'builtin',
label: '内置数据源',
children: (
<Table
columns={builtinColumns}
dataSource={builtInSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 800, y: 'auto' }}
tableLayout="fixed"
size="small"
/>
<div className="page-shell__body">
<div ref={builtinTableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={builtinColumns}
dataSource={builtInSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 800, y: builtinTableHeight }}
tableLayout="fixed"
size="small"
virtual
/>
</div>
</div>
),
},
{
@@ -460,35 +491,48 @@ function DataSources() {
</span>
),
children: (
<>
<div style={{ marginBottom: 16, textAlign: 'right' }}>
<div className="page-shell__body data-source-custom-tab">
<div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
</Button>
</div>
{customSources.length === 0 ? (
<Empty description="暂无自定义数据源" />
<div className="data-source-empty-state">
<Empty description="暂无自定义数据源" />
</div>
) : (
<Table
columns={customColumns}
dataSource={customSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 600, y: 'auto' }}
tableLayout="fixed"
size="small"
/>
<div ref={customTableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={customColumns}
dataSource={customSources}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 600, y: customTableHeight }}
tableLayout="fixed"
size="small"
virtual
/>
</div>
)}
</>
</div>
),
},
]
return (
<AppLayout>
<h2></h2>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<div className="page-shell">
<div className="page-shell__header">
<h2 style={{ margin: 0 }}></h2>
</div>
<div className="page-shell__body">
<div className="data-source-tabs-shell">
<Tabs className="data-source-tabs" activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</div>
</div>
</div>
<Drawer
title={editingConfig ? '编辑数据源' : '添加数据源'}

View File

@@ -1,37 +1,23 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import {
Layout,
Menu,
Card,
Row,
Col,
Typography,
Button,
Card,
Form,
Input,
Switch,
Select,
Divider,
message,
Spin,
Tabs,
InputNumber,
message,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import {
SettingOutlined,
DashboardOutlined,
DatabaseOutlined,
UserOutlined,
BellOutlined,
SafetyOutlined,
SaveOutlined,
} from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
const { Header, Sider, Content } = Layout
const { Title, Text } = Typography
const { TabPane } = Tabs
interface SystemSettings {
system_name: string
@@ -55,367 +41,293 @@ interface SecuritySettings {
password_policy: string
}
function Settings() {
const { user, logout, token, clearAuth } = useAuthStore()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
system_name: '智能星球',
refresh_interval: 60,
auto_refresh: true,
data_retention_days: 30,
max_concurrent_tasks: 5,
})
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
email_enabled: false,
email_address: '',
critical_alerts: true,
warning_alerts: true,
daily_summary: false,
})
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
session_timeout: 60,
max_login_attempts: 5,
password_policy: 'medium',
})
const [form] = Form.useForm()
interface CollectorSettings {
id: number
name: string
source: string
module: string
priority: string
frequency_minutes: number
frequency: string
is_active: boolean
last_run_at: string | null
last_status: string | null
next_run_at: string | null
}
useEffect(() => {
if (!token) {
navigate('/')
return
}
fetchSettings()
}, [token, navigate])
function Settings() {
const [loading, setLoading] = useState(true)
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>()
const fetchSettings = async () => {
try {
setLoading(true)
const res = await fetch('/api/v1/settings/system', {
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401) {
clearAuth()
navigate('/')
return
}
if (res.ok) {
const data = await res.json()
setSystemSettings(data.system || systemSettings)
setNotificationSettings(data.notifications || notificationSettings)
setSecuritySettings(data.security || securitySettings)
form.setFieldsValue({
...data.system,
...data.notifications,
...data.security,
})
}
} catch (err) {
message.error('获取设置失败')
console.error(err)
const response = await axios.get('/api/v1/settings')
systemForm.setFieldsValue(response.data.system)
notificationForm.setFieldsValue(response.data.notifications)
securityForm.setFieldsValue(response.data.security)
setCollectors(response.data.collectors || [])
} catch (error) {
message.error('获取系统配置失败')
console.error(error)
} finally {
setLoading(false)
}
}
const handleSaveSystem = async (values: any) => {
useEffect(() => {
fetchSettings()
}, [])
const saveSection = async (section: 'system' | 'notifications' | 'security', values: object) => {
try {
setSaving(true)
const res = await fetch('/api/v1/settings/system', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('系统设置已保存')
setSystemSettings(values)
} else {
message.error('保存失败')
}
} catch (err) {
message.error('保存设置失败')
console.error(err)
} finally {
setSaving(false)
await axios.put(`/api/v1/settings/${section}`, values)
message.success('配置已保存')
await fetchSettings()
} catch (error) {
message.error('保存失败')
console.error(error)
}
}
const handleSaveNotifications = async (values: any) => {
try {
setSaving(true)
const res = await fetch('/api/v1/settings/notifications', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('通知设置已保存')
setNotificationSettings(values)
} else {
message.error('保存失败')
}
} catch (err) {
message.error('保存设置失败')
console.error(err)
} finally {
setSaving(false)
}
}
const handleSaveSecurity = async (values: any) => {
try {
setSaving(true)
const res = await fetch('/api/v1/settings/security', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('安全设置已保存')
setSecuritySettings(values)
} else {
message.error('保存失败')
}
} catch (err) {
message.error('保存设置失败')
console.error(err)
} finally {
setSaving(false)
}
}
const handleLogout = () => {
logout()
navigate('/')
}
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/"></Link> },
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources"></Link> },
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users"></Link> },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
]
if (loading && !token) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" tip="加载中..." />
</div>
const updateCollectorField = (id: number, field: keyof CollectorSettings, value: string | number | boolean) => {
setCollectors((prev) =>
prev.map((collector) => (collector.id === id ? { ...collector, [field]: value } : collector))
)
}
return (
<Layout className="dashboard-layout">
<Sider width={240} className="dashboard-sider">
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Title level={4} style={{ color: 'white', margin: 0 }}></Title>
const saveCollector = async (collector: CollectorSettings) => {
try {
setSavingCollectorId(collector.id)
await axios.put(`/api/v1/settings/collectors/${collector.id}`, {
is_active: collector.is_active,
priority: collector.priority,
frequency_minutes: collector.frequency_minutes,
})
message.success(`${collector.name} 配置已更新`)
await fetchSettings()
} catch (error) {
message.error('采集调度配置保存失败')
console.error(error)
} finally {
setSavingCollectorId(null)
}
}
const collectorColumns = [
{
title: '数据源',
dataIndex: 'name',
key: 'name',
render: (_: string, record: CollectorSettings) => (
<div>
<div>{record.name}</div>
<Text type="secondary">{record.source}</Text>
</div>
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/settings']} items={menuItems} />
</Sider>
<Layout>
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text strong>, {user?.username}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button type="link" danger onClick={handleLogout}>退</Button>
</div>
</Header>
<Content className="dashboard-content">
<Title level={3}><SettingOutlined /> </Title>
<Tabs defaultActiveKey="system" tabPosition="left">
<TabPane
tab={<span><SettingOutlined /> </span>}
key="system"
>
<Card title="基本设置">
<Form
form={form}
layout="vertical"
onFinish={handleSaveSystem}
initialValues={systemSettings}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="system_name"
label="系统名称"
rules={[{ required: true, message: '请输入系统名称' }]}
>
<Input placeholder="智能星球" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="refresh_interval"
label="数据刷新间隔 (秒)"
>
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="data_retention_days"
label="数据保留天数"
>
<InputNumber min={1} max={365} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="max_concurrent_tasks"
label="最大并发任务数"
>
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="auto_refresh"
label="自动刷新"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
</Form.Item>
</Form>
</Card>
</TabPane>
<TabPane
tab={<span><BellOutlined /> </span>}
key="notifications"
>
<Card title="通知配置">
<Form
form={form}
layout="vertical"
onFinish={handleSaveNotifications}
initialValues={notificationSettings}
>
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="email_enabled"
label="启用邮件通知"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="email_address"
label="通知邮箱"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input placeholder="admin@example.com" disabled={!notificationSettings.email_enabled} />
</Form.Item>
</Col>
</Row>
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="critical_alerts"
label="严重告警"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="warning_alerts"
label="警告告警"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="daily_summary"
label="每日摘要"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
</Form.Item>
</Form>
</Card>
</TabPane>
<TabPane
tab={<span><SafetyOutlined /> </span>}
key="security"
>
<Card title="安全配置">
<Form
form={form}
layout="vertical"
onFinish={handleSaveSecurity}
initialValues={securitySettings}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="session_timeout"
label="会话超时 (分钟)"
>
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="max_login_attempts"
label="最大登录尝试次数"
>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="password_policy"
label="密码策略"
>
<Select>
<Select.Option value="low"> (6)</Select.Option>
<Select.Option value="medium"> (8,)</Select.Option>
<Select.Option value="high"> (12,)</Select.Option>
</Select>
</Form.Item>
<Divider />
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
</Form>
</Card>
</TabPane>
</Tabs>
</Content>
</Layout>
</Layout>
),
},
{
title: '层级',
dataIndex: 'module',
key: 'module',
width: 90,
render: (module: string) => <Tag color="blue">{module}</Tag>,
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 130,
render: (priority: string, record: CollectorSettings) => (
<Select
value={priority}
style={{ width: '100%' }}
onChange={(value) => updateCollectorField(record.id, 'priority', value)}
options={[
{ value: 'P0', label: 'P0' },
{ value: 'P1', label: 'P1' },
{ value: 'P2', label: 'P2' },
]}
/>
),
},
{
title: '频率(分钟)',
dataIndex: 'frequency_minutes',
key: 'frequency_minutes',
width: 150,
render: (value: number, record: CollectorSettings) => (
<InputNumber
min={1}
max={10080}
value={value}
style={{ width: '100%' }}
onChange={(nextValue) => updateCollectorField(record.id, 'frequency_minutes', nextValue || 1)}
/>
),
},
{
title: '启用',
dataIndex: 'is_active',
key: 'is_active',
width: 90,
render: (value: boolean, record: CollectorSettings) => (
<Switch checked={value} onChange={(checked) => updateCollectorField(record.id, 'is_active', checked)} />
),
},
{
title: '上次执行',
dataIndex: 'last_run_at',
key: 'last_run_at',
width: 180,
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
},
{
title: '下次执行',
dataIndex: 'next_run_at',
key: 'next_run_at',
width: 180,
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
},
{
title: '状态',
dataIndex: 'last_status',
key: 'last_status',
width: 120,
render: (value: string | null) => {
if (!value) return <Tag></Tag>
const color = value === 'success' ? 'success' : value === 'failed' ? 'error' : 'default'
return <Tag color={color}>{value}</Tag>
},
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right' as const,
render: (_: unknown, record: CollectorSettings) => (
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}>
</Button>
),
},
]
return (
<AppLayout>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary"></Text>
</div>
<Tabs
items={[
{
key: 'system',
label: '系统显示',
children: (
<Card loading={loading}>
<Form form={systemForm} layout="vertical" onFinish={(values) => saveSection('system', values)}>
<Form.Item name="system_name" label="系统名称" rules={[{ required: true, message: '请输入系统名称' }]}>
<Input />
</Form.Item>
<Form.Item name="refresh_interval" label="默认刷新间隔(秒)">
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="data_retention_days" label="数据保留天数">
<InputNumber min={1} max={3650} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_concurrent_tasks" label="最大并发任务数">
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</Card>
),
},
{
key: 'notifications',
label: '通知策略',
children: (
<Card loading={loading}>
<Form form={notificationForm} layout="vertical" onFinish={(values) => saveSection('notifications', values)}>
<Form.Item name="email_enabled" label="启用邮件通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="email_address" label="通知邮箱">
<Input />
</Form.Item>
<Form.Item name="critical_alerts" label="严重告警通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="warning_alerts" label="警告告警通知" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="daily_summary" label="每日摘要" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</Card>
),
},
{
key: 'security',
label: '安全策略',
children: (
<Card loading={loading}>
<Form form={securityForm} layout="vertical" onFinish={(values) => saveSection('security', values)}>
<Form.Item name="session_timeout" label="会话超时(分钟)">
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_login_attempts" label="最大登录尝试次数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="password_policy" label="密码策略">
<Select
options={[
{ value: 'low', label: '简单' },
{ value: 'medium', label: '中等' },
{ value: 'high', label: '严格' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
</Card>
),
},
{
key: 'collectors',
label: '采集调度',
children: (
<Card loading={loading}>
<div className="table-scroll-region">
<Table
rowKey="id"
columns={collectorColumns}
dataSource={collectors}
pagination={false}
scroll={{ x: 1200, y: 'calc(100% - 360px)' }}
/>
</div>
</Card>
),
},
]}
/>
</Space>
</AppLayout>
)
}
export default Settings

View File

@@ -145,7 +145,9 @@ function Tasks() {
</Button>
}
>
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
<div className="table-scroll-region">
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
</div>
</Card>
</AppLayout>
)

View File

@@ -115,11 +115,17 @@ function Users() {
return (
<AppLayout>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
<div className="page-shell">
<div className="page-shell__header">
<h2 style={{ margin: 0 }}></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
<div className="page-shell__body">
<div className="table-scroll-region" style={{ height: '100%' }}>
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content', y: 'calc(100% - 72px)' }} tableLayout="fixed" />
</div>
</div>
</div>
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content' }} tableLayout="fixed" />
<Modal
title={editingUser ? '编辑用户' : '添加用户'}
open={modalVisible}