feat: persist system settings and refine admin layouts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? '编辑数据源' : '添加数据源'}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user