Refine data management and collection workflows

This commit is contained in:
linkong
2026-03-25 17:19:10 +08:00
parent cc5f16f8a7
commit 020c1d5051
34 changed files with 3341 additions and 947 deletions

View File

@@ -231,6 +231,10 @@ body {
overflow: hidden;
}
.data-source-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.data-source-custom-tab {
gap: 12px;
}
@@ -340,6 +344,42 @@ body {
min-width: 100%;
}
.table-scroll-region .ant-table-thead > tr > th,
.table-scroll-region .ant-table-tbody > tr > td {
padding: 10px 12px !important;
}
.table-scroll-region .ant-table-body,
.table-scroll-region .ant-table-content {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
}
.table-scroll-region .ant-table-body::-webkit-scrollbar,
.table-scroll-region .ant-table-content::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb,
.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb:hover,
.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
background-clip: padding-box;
}
.table-scroll-region .ant-table-body::-webkit-scrollbar-track,
.table-scroll-region .ant-table-content::-webkit-scrollbar-track {
background: transparent;
}
.settings-shell,
.settings-tabs-shell,
.settings-tabs,
@@ -377,7 +417,7 @@ body {
display: none !important;
}
.settings-tab-panel {
.settings-pane {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
@@ -427,9 +467,22 @@ body {
background: transparent;
}
.settings-table-scroll-region {
.settings-pane .data-source-table-region .ant-table-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.settings-pane .data-source-table-region .ant-table-header {
flex: 0 0 auto;
}
.settings-pane .data-source-table-region .ant-table-body {
flex: 1 1 auto;
overflow: hidden;
min-height: 0;
height: 0 !important;
max-height: none !important;
}
@@ -490,6 +543,10 @@ body {
overflow: auto;
}
.data-list-summary-card-inner {
min-height: 100%;
}
.data-list-right-column {
min-width: 0;
min-height: 0;
@@ -499,7 +556,9 @@ body {
}
.data-list-summary-treemap {
min-height: 100%;
--data-list-treemap-tile-padding: 12px;
--data-list-treemap-label-size: 12px;
--data-list-treemap-value-size: 16px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: minmax(56px, 1fr);
@@ -512,9 +571,9 @@ body {
min-height: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 12px;
justify-content: flex-start;
gap: 6px;
padding: var(--data-list-treemap-tile-padding);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.55);
color: #0f172a;
@@ -552,29 +611,36 @@ body {
.data-list-treemap-head {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
min-width: 0;
flex: 0 0 auto;
}
.data-list-treemap-label {
min-width: 0;
font-size: clamp(11px, 0.75vw, 13px);
font-size: var(--data-list-treemap-label-size);
line-height: 1.2;
color: rgba(15, 23, 42, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.data-list-treemap-body {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
margin-top: auto;
min-height: 0;
flex: 0 0 auto;
}
.data-list-summary-tile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 22px;
height: 22px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
color: #0f172a;
@@ -582,9 +648,12 @@ body {
}
.data-list-summary-tile-value {
font-size: clamp(12px, 1vw, 16px);
font-size: var(--data-list-treemap-value-size);
line-height: 1.1;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.data-list-treemap-meta {
@@ -611,7 +680,7 @@ body {
display: flex;
flex-wrap: nowrap;
gap: 10px;
align-items: center;
align-items: flex-start;
}
.data-list-filter-grid--balanced > * {
@@ -687,6 +756,46 @@ body {
margin: 12px 0 0;
}
.data-list-name-link {
max-width: 100%;
display: inline-flex;
align-items: center;
justify-content: flex-start;
padding-inline: 0 !important;
}
.data-list-name-marquee {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.data-list-name-marquee--overflow {
width: 100%;
}
.data-list-name-marquee__text {
display: inline-block;
max-width: 100%;
white-space: nowrap;
transform: translateX(0);
will-change: transform;
}
.data-list-name-link:hover .data-list-name-marquee--overflow .data-list-name-marquee__text {
animation: data-list-name-marquee 8s linear infinite;
}
@keyframes data-list-name-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
.data-list-resize-handle {
position: relative;
display: flex;
@@ -807,3 +916,172 @@ body {
}
}
.data-list-detail-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
.data-list-detail-section {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.data-list-detail-section__title {
font-size: 14px;
}
.data-list-detail-hero {
padding: 14px 16px;
border-radius: 12px;
background: #f7f8fa;
border: 1px solid #eef1f5;
}
.data-list-detail-hero__label {
display: block;
margin-bottom: 6px;
color: #6b7280;
font-size: 12px;
}
.data-list-detail-hero__title.ant-typography {
margin: 0;
overflow-wrap: anywhere;
}
.data-list-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.data-list-detail-cell {
min-width: 0;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid #eef1f5;
background: #fff;
}
.data-list-detail-cell--block {
grid-column: 1 / -1;
}
.data-list-detail-cell__label {
display: block;
margin-bottom: 8px;
color: #6b7280;
font-size: 12px;
}
.data-list-detail-cell__value {
color: #111827;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.data-list-detail-code {
margin: 0;
padding: 12px;
max-height: 240px;
overflow: auto;
border-radius: 10px;
background: #111827;
color: #e5eef9;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.data-list-detail-code--raw {
max-height: 320px;
}
.data-list-tag-cell {
min-width: 140px;
}
.data-list-tag-cell .ant-tag {
display: inline-block;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.4;
}
.data-list-filter-select {
max-width: 220px;
}
.data-list-filter-select .ant-select-selector {
height: auto !important;
min-height: 32px;
max-height: 72px;
align-items: flex-start !important;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.data-list-filter-select .ant-select-selection-overflow {
flex-wrap: wrap !important;
}
.data-list-filter-select .ant-select-selection-overflow-item {
max-width: 100%;
}
.data-list-filter-select .ant-select-selection-item {
max-width: 100%;
}
.dashboard-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-page__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.dashboard-page__actions {
align-items: center;
}
.dashboard-status-tag {
margin-inline-end: 0 !important;
padding-inline: 10px;
border-radius: 999px;
line-height: 24px;
}
.dashboard-refresh-button.ant-btn {
height: 26px;
padding-inline: 12px;
border-radius: 999px;
border-color: #d9d9d9;
background: #ffffff;
color: rgba(0, 0, 0, 0.88);
box-shadow: none;
}
.dashboard-refresh-button.ant-btn:hover,
.dashboard-refresh-button.ant-btn:focus {
border-color: #bfbfbf;
background: #ffffff;
color: rgba(0, 0, 0, 0.88);
}

View File

@@ -14,7 +14,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
},
}}
>
<BrowserRouter>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
</BrowserRouter>
</ConfigProvider>

View File

@@ -122,19 +122,19 @@ function Dashboard() {
return (
<AppLayout>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
<div className="dashboard-page">
<div className="dashboard-page__header">
<div>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"></Text>
</div>
<Space wrap>
<Space wrap className="dashboard-page__actions">
{wsConnected ? (
<Tag icon={<WifiOutlined />} color="success"></Tag>
<Tag className="dashboard-status-tag" icon={<WifiOutlined />} color="success"></Tag>
) : (
<Tag icon={<DisconnectOutlined />} color="default">线</Tag>
<Tag className="dashboard-status-tag" icon={<DisconnectOutlined />} color="default">线</Tag>
)}
<Button type="default" icon={<ReloadOutlined />} onClick={handleRetry}></Button>
<Button className="dashboard-refresh-button" icon={<ReloadOutlined />} onClick={handleRetry}></Button>
</Space>
</div>
@@ -188,7 +188,7 @@ function Dashboard() {
{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>}
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}></Tag>}
</div>
)}
</div>

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
Table, Tag, Space, Card, Select, Input, Button,
Modal, Descriptions, Spin, Empty, Tooltip, Typography, Grid
Modal, Spin, Empty, Tooltip, Typography, Grid
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
import {
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
@@ -28,6 +29,10 @@ interface CollectedData {
longitude: string | null
value: string | null
unit: string | null
cores: string | null
rmax: string | null
rpeak: string | null
power: string | null
metadata: Record<string, any> | null
collected_at: string
reference_date: string | null
@@ -40,6 +45,183 @@ interface Summary {
source_totals: Array<{ source: string; count: number }>
}
const DETAIL_FIELD_LABELS: Record<string, string> = {
id: 'ID',
source: '数据源',
source_id: '原始ID',
data_type: '数据类型',
name: '名称',
title: '标题',
description: '描述',
country: '国家',
city: '城市',
latitude: '纬度',
longitude: '经度',
value: '数值',
unit: '单位',
collected_at: '采集时间',
reference_date: '参考日期',
is_valid: '有效状态',
rank: '排名',
cores: '核心数量',
rmax: '实际最大算力',
rpeak: '理论算力',
power: '功耗',
manufacturer: '厂商',
site: '站点',
processor: '处理器',
interconnect: '互连',
installation_year: '安装年份',
nmax: 'Nmax',
hpcg: 'HPCG',
power_measurement_level: '功耗测量等级',
operating_system: '操作系统',
compiler: '编译器',
math_library: '数学库',
mpi: 'MPI',
raw_country: '原始国家值',
country_validation: '国家校验',
}
const DETAIL_BASE_FIELDS = [
'source',
'data_type',
'source_id',
'country',
'city',
'collected_at',
'reference_date',
]
function formatFieldLabel(key: string) {
if (DETAIL_FIELD_LABELS[key]) {
return DETAIL_FIELD_LABELS[key]
}
return key
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function formatDetailValue(key: string, value: unknown) {
if (value === null || value === undefined || value === '') {
return '-'
}
if (key === 'collected_at' || key === 'reference_date') {
const date = new Date(String(value))
return Number.isNaN(date.getTime())
? String(value)
: key === 'reference_date'
? date.toLocaleDateString('zh-CN')
: date.toLocaleString('zh-CN')
}
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2)
}
return String(value)
}
function NameMarquee({ text }: { text: string }) {
const containerRef = useRef<HTMLSpanElement | null>(null)
const textRef = useRef<HTMLSpanElement | null>(null)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
const updateOverflow = () => {
const container = containerRef.current
const content = textRef.current
if (!container || !content) return
setOverflowing(content.scrollWidth > container.clientWidth + 1)
}
updateOverflow()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateOverflow)
if (containerRef.current) observer.observe(containerRef.current)
if (textRef.current) observer.observe(textRef.current)
return () => observer.disconnect()
}, [text])
return (
<span
ref={containerRef}
className={`data-list-name-marquee${overflowing ? ' data-list-name-marquee--overflow' : ''}`}
>
<span ref={textRef} className="data-list-name-marquee__text">
{text}
</span>
</span>
)
}
function estimateTreemapRows(
items: Array<{ colSpan: number; rowSpan: number }>,
columns: number
): number {
const occupancy: boolean[][] = []
const ensureRow = (rowIndex: number) => {
while (occupancy.length <= rowIndex) {
occupancy.push(Array(columns).fill(false))
}
}
for (const item of items) {
let placed = false
let rowIndex = 0
while (!placed) {
ensureRow(rowIndex)
for (let columnIndex = 0; columnIndex <= columns - item.colSpan; columnIndex += 1) {
let canPlace = true
for (let rowOffset = 0; rowOffset < item.rowSpan; rowOffset += 1) {
ensureRow(rowIndex + rowOffset)
for (let columnOffset = 0; columnOffset < item.colSpan; columnOffset += 1) {
if (occupancy[rowIndex + rowOffset][columnIndex + columnOffset]) {
canPlace = false
break
}
}
if (!canPlace) break
}
if (!canPlace) continue
for (let rowOffset = 0; rowOffset < item.rowSpan; rowOffset += 1) {
for (let columnOffset = 0; columnOffset < item.colSpan; columnOffset += 1) {
occupancy[rowIndex + rowOffset][columnIndex + columnOffset] = true
}
}
placed = true
break
}
rowIndex += 1
}
}
return Math.max(occupancy.length, 1)
}
function DataList() {
const screens = useBreakpoint()
const isCompact = !screens.lg
@@ -48,6 +230,7 @@ function DataList() {
const mainAreaRef = useRef<HTMLDivElement | null>(null)
const rightColumnRef = useRef<HTMLDivElement | null>(null)
const tableHeaderRef = useRef<HTMLDivElement | null>(null)
const summaryBodyRef = useRef<HTMLDivElement | null>(null)
const hasCustomLeftWidthRef = useRef(false)
const [mainAreaWidth, setMainAreaWidth] = useState(0)
@@ -55,6 +238,7 @@ function DataList() {
const [rightColumnHeight, setRightColumnHeight] = useState(0)
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
const [summaryBodyHeight, setSummaryBodyHeight] = useState(0)
const [data, setData] = useState<CollectedData[]>([])
const [loading, setLoading] = useState(false)
@@ -62,13 +246,11 @@ function DataList() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sourceFilter, setSourceFilter] = useState<string | undefined>()
const [typeFilter, setTypeFilter] = useState<string | undefined>()
const [countryFilter, setCountryFilter] = useState<string | undefined>()
const [sourceFilter, setSourceFilter] = useState<string[]>([])
const [typeFilter, setTypeFilter] = useState<string[]>([])
const [searchText, setSearchText] = useState('')
const [sources, setSources] = useState<string[]>([])
const [types, setTypes] = useState<string[]>([])
const [countries, setCountries] = useState<string[]>([])
const [detailVisible, setDetailVisible] = useState(false)
const [detailData, setDetailData] = useState<CollectedData | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
@@ -79,6 +261,7 @@ function DataList() {
setMainAreaHeight(mainAreaRef.current?.offsetHeight || 0)
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
}
updateLayout()
@@ -93,6 +276,7 @@ function DataList() {
if (mainAreaRef.current) observer.observe(mainAreaRef.current)
if (rightColumnRef.current) observer.observe(rightColumnRef.current)
if (tableHeaderRef.current) observer.observe(tableHeaderRef.current)
if (summaryBodyRef.current) observer.observe(summaryBodyRef.current)
return () => observer.disconnect()
}, [isCompact])
@@ -147,9 +331,8 @@ function DataList() {
page: page.toString(),
page_size: pageSize.toString(),
})
if (sourceFilter) params.append('source', sourceFilter)
if (typeFilter) params.append('data_type', typeFilter)
if (countryFilter) params.append('country', countryFilter)
if (sourceFilter.length > 0) params.append('source', sourceFilter.join(','))
if (typeFilter.length > 0) params.append('data_type', typeFilter.join(','))
if (searchText) params.append('search', searchText)
const res = await axios.get(`/api/v1/collected?${params}`)
@@ -173,14 +356,12 @@ function DataList() {
const fetchFilters = async () => {
try {
const [sourcesRes, typesRes, countriesRes] = await Promise.all([
const [sourcesRes, typesRes] = await Promise.all([
axios.get('/api/v1/collected/sources'),
axios.get('/api/v1/collected/types'),
axios.get('/api/v1/collected/countries'),
])
setSources(sourcesRes.data.sources || [])
setTypes(typesRes.data.data_types || [])
setCountries(countriesRes.data.countries || [])
} catch (error) {
console.error('Failed to fetch filters:', error)
}
@@ -193,7 +374,7 @@ function DataList() {
useEffect(() => {
fetchData()
}, [page, pageSize, sourceFilter, typeFilter, countryFilter])
}, [page, pageSize, sourceFilter, typeFilter])
const handleSearch = () => {
setPage(1)
@@ -201,9 +382,8 @@ function DataList() {
}
const handleReset = () => {
setSourceFilter(undefined)
setTypeFilter(undefined)
setCountryFilter(undefined)
setSourceFilter([])
setTypeFilter([])
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
@@ -234,6 +414,47 @@ function DataList() {
return iconMap[source] || <DatabaseOutlined />
}
const getSourceTagColor = (source: string) => {
const colorMap: Record<string, string> = {
top500: 'geekblue',
huggingface_models: 'purple',
huggingface_datasets: 'cyan',
huggingface_spaces: 'magenta',
telegeography_cables: 'green',
epoch_ai_gpu: 'volcano',
}
return colorMap[source] || 'blue'
}
const getDataTypeTagColor = (dataType: string) => {
const colorMap: Record<string, string> = {
supercomputer: 'geekblue',
model: 'purple',
dataset: 'cyan',
space: 'magenta',
submarine_cable: 'green',
cable_landing_point: 'lime',
cable_landing_relation: 'gold',
gpu_cluster: 'volcano',
generic: 'default',
}
return colorMap[dataType] || 'default'
}
const renderFilterTag = (tagProps: CustomTagProps, getColor: (value: string) => string) => {
const { label, value, closable, onClose } = tagProps
return (
<Tag
color={getColor(String(value))}
closable={closable}
onClose={onClose}
style={{ marginInlineEnd: 4 }}
>
{label}
</Tag>
)
}
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
supercomputer: 'red',
@@ -250,8 +471,8 @@ function DataList() {
}
const activeFilterCount = useMemo(
() => [sourceFilter, typeFilter, countryFilter, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, countryFilter, searchText]
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, searchText]
)
const summaryItems = useMemo(() => {
@@ -281,30 +502,24 @@ function DataList() {
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
const allowFeaturedTile = !isCompact && treemapColumns > 1 && summaryItems.length > 2
const allowSecondaryTallTiles = !isCompact && leftPanelWidth >= 520
return summaryItems.map((item, index) => {
const ratio = item.value / maxValue
let colSpan = 1
let rowSpan = 1
if (allowTallTiles && index === 0) {
if (allowFeaturedTile && index === 0) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowTallTiles && ratio >= 0.7) {
} else if (allowSecondaryTallTiles && ratio >= 0.7) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowTallTiles && ratio >= 0.35) {
} else if (allowSecondaryTallTiles && ratio >= 0.35) {
rowSpan = 2
}
@@ -317,27 +532,70 @@ function DataList() {
})
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
const treemapRows = useMemo(
() => estimateTreemapRows(treemapItems, treemapColumns),
[treemapColumns, treemapItems]
)
const treemapGap = isCompact ? 8 : 10
const treemapMinRowHeight = isCompact ? 88 : 68
const treemapTargetRowHeight = isCompact ? 88 : leftPanelWidth < 360 ? 44 : leftPanelWidth < 520 ? 48 : 56
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
const treemapAutoRowHeight = treemapRows > 0
? Math.floor((treemapAvailableHeight - Math.max(0, treemapRows - 1) * treemapGap) / treemapRows)
: treemapTargetRowHeight
const treemapRowHeight = Math.max(
treemapMinRowHeight,
Math.min(treemapTargetRowHeight, treemapAutoRowHeight || treemapTargetRowHeight)
)
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12
const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12
const treemapValueSize = treemapRowHeight <= 72 ? 13 : treemapRowHeight <= 84 ? 15 : 16
const pageHeight = '100%'
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
const compactTableHeight = mainAreaHeight - tableHeaderHeight - 156
const tableHeight = Math.max(180, isCompact ? compactTableHeight : desktopTableHeight)
const detailBaseItems = useMemo(() => {
if (!detailData) return []
return DETAIL_BASE_FIELDS.map((key) => ({
key,
label: formatFieldLabel(key),
value: formatDetailValue(key, detailData[key as keyof CollectedData]),
})).filter((item) => item.value !== '-')
}, [detailData])
const detailMetadataItems = useMemo(() => {
if (!detailData?.metadata) return []
return Object.entries(detailData.metadata)
.filter(([key]) => key !== '_detail_url')
.map(([key, value]) => ({
key,
label: formatFieldLabel(key),
value: formatDetailValue(key, value),
isBlock: typeof value === 'object' && value !== null,
}))
}, [detailData])
const splitLayoutStyle = isCompact
? undefined
: { gridTemplateColumns: `${leftPanelWidth}px 12px minmax(0, 1fr)` }
const columns: ColumnsType<CollectedData> = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 280,
width: 320,
ellipsis: true,
render: (name: string, record: CollectedData) => (
<Tooltip title={name}>
<Button type="link" onClick={() => handleViewDetail(record.id)}>
{name}
<Button type="link" className="data-list-name-link" onClick={() => handleViewDetail(record.id)}>
<NameMarquee text={name} />
</Button>
</Tooltip>
),
@@ -346,23 +604,31 @@ function DataList() {
title: '数据源',
dataIndex: 'source',
key: 'source',
width: 170,
render: (source: string) => <Tag icon={getSourceIcon(source)}>{source}</Tag>,
minWidth: 140,
render: (value: string) => (
value ? (
<div className="data-list-tag-cell">
<Tag color={getSourceTagColor(value)} style={{ marginInlineEnd: 0 }}>
{value}
</Tag>
</div>
) : '-'
),
},
{
title: '类型',
title: '数据类型',
dataIndex: 'data_type',
key: 'data_type',
width: 120,
render: (type: string) => <Tag color={getTypeColor(type)}>{type}</Tag>,
},
{ title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true },
{
title: '数值',
dataIndex: 'value',
key: 'value',
width: 140,
render: (value: string | null, record: CollectedData) => (value ? `${value} ${record.unit || ''}` : '-'),
minWidth: 140,
render: (value: string) => (
value ? (
<div className="data-list-tag-cell">
<Tag color={getDataTypeTagColor(value)} style={{ marginInlineEnd: 0 }}>
{value}
</Tag>
</div>
) : '-'
),
},
{
title: '采集时间',
@@ -371,6 +637,13 @@ function DataList() {
width: 180,
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
},
{
title: '参考日期',
dataIndex: 'reference_date',
key: 'reference_date',
width: 120,
render: (time: string | null) => (time ? new Date(time).toLocaleDateString('zh-CN') : '-'),
},
{
title: '操作',
key: 'action',
@@ -406,14 +679,21 @@ function DataList() {
className="data-list-summary-card data-list-summary-card--panel"
title="数据概览"
size="small"
bodyStyle={{ padding: isCompact ? 12 : 16 }}
styles={{ body: { padding: isCompact ? 12 : 16 } }}
>
<div ref={summaryBodyRef} className="data-list-summary-card-inner">
<div
className="data-list-summary-treemap"
style={{
gridTemplateColumns: `repeat(${treemapColumns}, minmax(0, 1fr))`,
gridAutoRows: `minmax(${treemapRowHeight}px, 1fr)`,
}}
gridAutoRows: `${treemapRowHeight}px`,
gap: treemapGap,
minHeight: treemapAvailableHeight > 0 ? Math.min(treemapContentHeight, treemapAvailableHeight) : undefined,
height: treemapContentHeight,
['--data-list-treemap-tile-padding' as '--data-list-treemap-tile-padding']: `${treemapTilePadding}px`,
['--data-list-treemap-label-size' as '--data-list-treemap-label-size']: `${treemapLabelSize}px`,
['--data-list-treemap-value-size' as '--data-list-treemap-value-size']: `${treemapValueSize}px`,
} as CSSProperties}
>
{treemapItems.map((item) => (
<div
@@ -436,6 +716,7 @@ function DataList() {
</div>
))}
</div>
</div>
</Card>
{!isCompact && (
@@ -449,7 +730,7 @@ function DataList() {
)}
<div ref={rightColumnRef} className="data-list-right-column">
<Card className="data-list-table-shell" bodyStyle={{ padding: 0 }}>
<Card className="data-list-table-shell" styles={{ body: { 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>
@@ -468,6 +749,7 @@ function DataList() {
<Select
size="middle"
placeholder="数据源"
mode="multiple"
allowClear
value={sourceFilter}
onChange={(value) => {
@@ -475,11 +757,14 @@ function DataList() {
setPage(1)
}}
options={sources.map((source) => ({ label: source, value: source }))}
tagRender={(tagProps) => renderFilterTag(tagProps, getSourceTagColor)}
style={{ width: '100%' }}
className="data-list-filter-select"
/>
<Select
size="middle"
placeholder="数据类型"
mode="multiple"
allowClear
value={typeFilter}
onChange={(value) => {
@@ -487,23 +772,13 @@ function DataList() {
setPage(1)
}}
options={types.map((type) => ({ label: type, value: type }))}
tagRender={(tagProps) => renderFilterTag(tagProps, getDataTypeTagColor)}
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%' }}
className="data-list-filter-select"
/>
<Input
size="middle"
placeholder="搜索名称"
placeholder="搜索名称、描述、元数据等"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
onPressEnter={handleSearch}
@@ -516,9 +791,8 @@ function DataList() {
dataSource={data}
rowKey="id"
loading={loading}
virtual
scroll={{ x: 'max-content', y: tableHeight }}
tableLayout="fixed"
tableLayout="auto"
size={isCompact ? 'small' : 'middle'}
pagination={{
current: page,
@@ -548,38 +822,65 @@ function DataList() {
</Button>,
]}
width={700}
width={880}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : detailData ? (
<Descriptions column={2} bordered>
<Descriptions.Item label="ID">{detailData.id}</Descriptions.Item>
<Descriptions.Item label="数据源">{detailData.source}</Descriptions.Item>
<Descriptions.Item label="数据类型">{detailData.data_type}</Descriptions.Item>
<Descriptions.Item label="原始ID">{detailData.source_id || '-'}</Descriptions.Item>
<Descriptions.Item label="名称" span={2}>{detailData.name}</Descriptions.Item>
<Descriptions.Item label="标题" span={2}>{detailData.title || '-'}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
<Descriptions.Item label="国家">{detailData.country || '-'}</Descriptions.Item>
<Descriptions.Item label="城市">{detailData.city || '-'}</Descriptions.Item>
<Descriptions.Item label="经度">{detailData.longitude || '-'}</Descriptions.Item>
<Descriptions.Item label="纬度">{detailData.latitude || '-'}</Descriptions.Item>
<Descriptions.Item label="数值">{detailData.value} {detailData.unit || ''}</Descriptions.Item>
<Descriptions.Item label="采集时间">
{new Date(detailData.collected_at).toLocaleString('zh-CN')}
</Descriptions.Item>
<Descriptions.Item label="参考日期">
{detailData.reference_date ? new Date(detailData.reference_date).toLocaleDateString('zh-CN') : '-'}
</Descriptions.Item>
<Descriptions.Item label="元数据" span={2}>
<pre style={{ margin: 0, maxHeight: 200, overflow: 'auto' }}>
<div className="data-list-detail-modal">
<section className="data-list-detail-section">
<div className="data-list-detail-hero">
<Text className="data-list-detail-hero__label"></Text>
<Title level={5} className="data-list-detail-hero__title">
{detailData.name || '-'}
</Title>
</div>
</section>
{detailBaseItems.length > 0 && (
<section className="data-list-detail-section">
<Text strong className="data-list-detail-section__title"></Text>
<div className="data-list-detail-grid">
{detailBaseItems.map((item) => (
<div key={item.key} className="data-list-detail-cell">
<Text className="data-list-detail-cell__label">{item.label}</Text>
<div className="data-list-detail-cell__value">{item.value}</div>
</div>
))}
</div>
</section>
)}
{detailMetadataItems.length > 0 && (
<section className="data-list-detail-section">
<Text strong className="data-list-detail-section__title"></Text>
<div className="data-list-detail-grid">
{detailMetadataItems.map((item) => (
<div
key={item.key}
className={`data-list-detail-cell${item.isBlock ? ' data-list-detail-cell--block' : ''}`}
>
<Text className="data-list-detail-cell__label">{item.label}</Text>
{item.isBlock ? (
<pre className="data-list-detail-code">{item.value}</pre>
) : (
<div className="data-list-detail-cell__value">{item.value}</div>
)}
</div>
))}
</div>
</section>
)}
<section className="data-list-detail-section">
<Text strong className="data-list-detail-section__title"></Text>
<pre className="data-list-detail-code data-list-detail-code--raw">
{JSON.stringify(detailData.metadata || {}, null, 2)}
</pre>
</Descriptions.Item>
</Descriptions>
</section>
</div>
) : (
<Empty description="暂无数据" />
)}

View File

@@ -7,7 +7,7 @@ import {
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
EditOutlined, DeleteOutlined, ApiOutlined,
CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined,
SyncOutlined, ClearOutlined
SyncOutlined, ClearOutlined, CopyOutlined
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
@@ -18,16 +18,28 @@ interface BuiltInDataSource {
module: string
priority: string
frequency: string
endpoint?: string
is_active: boolean
collector_class: string
last_run: string | null
is_running: boolean
task_id: number | null
progress: number | null
phase?: string | null
records_processed: number | null
total_records: number | null
}
interface TaskTrackerState {
task_id: number | null
is_running: boolean
progress: number
phase: string | null
status?: string | null
records_processed?: number | null
total_records?: number | null
}
interface CustomDataSource {
id: number
name: string
@@ -89,7 +101,7 @@ function DataSources() {
}
}
const [taskProgress, setTaskProgress] = useState<Record<number, { progress: number; is_running: boolean }>>({})
const [taskProgress, setTaskProgress] = useState<Record<number, TaskTrackerState>>({})
useEffect(() => {
fetchData()
@@ -118,80 +130,85 @@ function DataSources() {
}, [activeTab, builtInSources.length, customSources.length])
useEffect(() => {
const runningSources = builtInSources.filter(s => s.is_running)
if (runningSources.length === 0) return
const trackedSources = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return Boolean((trackedTask?.task_id ?? source.task_id) && (trackedTask?.is_running ?? source.is_running))
})
if (trackedSources.length === 0) return
const interval = setInterval(async () => {
const progressMap: Record<number, { progress: number; is_running: boolean }> = {}
const updates: Record<number, TaskTrackerState> = {}
await Promise.all(
runningSources.map(async (source) => {
trackedSources.map(async (source) => {
const trackedTaskId = taskProgress[source.id]?.task_id ?? source.task_id
if (!trackedTaskId) return
try {
const res = await axios.get(`/api/v1/datasources/${source.id}/task-status`)
progressMap[source.id] = {
const res = await axios.get(`/api/v1/datasources/${source.id}/task-status`, {
params: { task_id: trackedTaskId },
})
updates[source.id] = {
task_id: res.data.task_id ?? trackedTaskId,
progress: res.data.progress || 0,
is_running: res.data.is_running
is_running: !!res.data.is_running,
phase: res.data.phase || null,
status: res.data.status || null,
records_processed: res.data.records_processed,
total_records: res.data.total_records,
}
} catch {
progressMap[source.id] = { progress: 0, is_running: false }
updates[source.id] = {
task_id: trackedTaskId,
progress: 0,
is_running: false,
phase: 'failed',
status: 'failed',
}
}
})
)
setTaskProgress(prev => ({ ...prev, ...progressMap }))
setTaskProgress((prev) => {
const next = { ...prev, ...updates }
for (const [sourceId, state] of Object.entries(updates)) {
if (!state.is_running && state.status !== 'running') {
delete next[Number(sourceId)]
}
}
return next
})
if (Object.values(updates).some((state) => !state.is_running)) {
fetchData()
}
}, 2000)
return () => clearInterval(interval)
}, [builtInSources.map(s => s.id).join(',')])
}, [builtInSources, taskProgress])
const handleTrigger = async (id: number) => {
try {
await axios.post(`/api/v1/datasources/${id}/trigger`)
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
message.success('任务已触发')
// Trigger polling immediately
setTaskProgress(prev => ({ ...prev, [id]: { progress: 0, is_running: true } }))
// Also refresh data
setTaskProgress(prev => ({
...prev,
[id]: {
task_id: res.data.task_id ?? null,
progress: 0,
is_running: true,
phase: 'queued',
status: 'running',
},
}))
fetchData()
// Also fetch the running task status
pollTaskStatus(id)
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || '触发失败')
}
}
const pollTaskStatus = async (sourceId: number) => {
const poll = async () => {
try {
const res = await axios.get(`/api/v1/datasources/${sourceId}/task-status`)
const data = res.data
setTaskProgress(prev => ({ ...prev, [sourceId]: {
progress: data.progress || 0,
is_running: data.is_running
} }))
// Keep polling while running
if (data.is_running) {
setTimeout(poll, 2000)
} else {
// Task completed - refresh data and clear this source from progress
setTimeout(() => {
setTaskProgress(prev => {
const newState = { ...prev }
delete newState[sourceId]
return newState
})
}, 1000)
fetchData()
}
} catch {
// Stop polling on error
}
}
poll()
}
const handleToggle = async (id: number, current: boolean) => {
const endpoint = current ? 'disable' : 'enable'
try {
@@ -229,7 +246,7 @@ function DataSources() {
name: data.name,
description: null,
source_type: data.collector_class,
endpoint: '',
endpoint: data.endpoint || '',
auth_type: 'none',
headers: {},
config: {},
@@ -340,6 +357,27 @@ function DataSources() {
setTestResult(null)
}
const handleCopyLink = async (value: string, successText: string) => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
} else {
const textArea = document.createElement('textarea')
textArea.value = value
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
}
message.success(successText)
} catch {
message.error('复制失败,请手动复制')
}
}
const builtinColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const },
{
@@ -374,15 +412,31 @@ function DataSources() {
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 100,
width: 180,
render: (_: unknown, record: BuiltInDataSource) => {
const progress = taskProgress[record.id]
if (progress?.is_running || record.is_running) {
const pct = progress?.progress ?? record.progress ?? 0
const taskState = taskProgress[record.id]
const isTaskRunning = taskState?.is_running || record.is_running
const phaseLabelMap: Record<string, string> = {
queued: '排队中',
fetching: '抓取中',
transforming: '处理中',
saving: '保存中',
completed: '已完成',
failed: '失败',
}
if (isTaskRunning) {
const pct = taskState?.progress ?? record.progress ?? 0
const phase = taskState?.phase || record.phase || 'queued'
return (
<Tag color="blue">
{Math.round(pct)}%
</Tag>
<Space size={6} wrap>
<Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
<Tag color="processing">
{phaseLabelMap[phase] || phase}
{pct > 0 ? ` ${Math.round(pct)}%` : ''}
</Tag>
</Space>
)
}
return <Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
@@ -420,6 +474,22 @@ function DataSources() {
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const },
{ title: '名称', dataIndex: 'name', key: 'name', width: 150, ellipsis: true },
{ title: '类型', dataIndex: 'source_type', key: 'source_type', width: 100 },
{
title: 'API链接',
dataIndex: 'endpoint',
key: 'endpoint',
width: 280,
ellipsis: true,
render: (endpoint: string) => (
endpoint ? (
<Tooltip title={endpoint}>
<a href={endpoint} target="_blank" rel="noreferrer">
{endpoint}
</a>
</Tooltip>
) : '-'
),
},
{
title: '状态',
dataIndex: 'is_active',
@@ -477,7 +547,6 @@ function DataSources() {
scroll={{ x: 800, y: builtinTableHeight }}
tableLayout="fixed"
size="small"
virtual
/>
</div>
</div>
@@ -509,10 +578,9 @@ function DataSources() {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 600, y: customTableHeight }}
scroll={{ x: 900, y: customTableHeight }}
tableLayout="fixed"
size="small"
virtual
/>
</div>
)}
@@ -811,6 +879,19 @@ function DataSources() {
<Input value={viewingSource.frequency} disabled />
</Form.Item>
<Form.Item label="采集源 API 链接">
<Space.Compact style={{ width: '100%' }}>
<Input value={viewingSource.endpoint || '-'} readOnly />
<Tooltip title={viewingSource.endpoint ? '复制采集源 API 链接' : '当前没有可复制的采集源 API 链接'}>
<Button
disabled={!viewingSource.endpoint}
icon={<CopyOutlined />}
onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
<Collapse
items={[
{

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
Button,
Card,
@@ -55,6 +55,22 @@ interface CollectorSettings {
next_run_at: string | null
}
function SettingsPanel({
loading,
children,
}: {
loading: boolean
children: ReactNode
}) {
return (
<div className="settings-pane">
<Card className="settings-panel-card" loading={loading}>
<div className="settings-panel-scroll">{children}</div>
</Card>
</div>
)
}
function Settings() {
const [loading, setLoading] = useState(true)
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
@@ -227,7 +243,7 @@ function Settings() {
{
title: '操作',
key: 'action',
width: 120,
width: 92,
fixed: 'right' as const,
render: (_: unknown, record: CollectorSettings) => (
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}>
@@ -237,6 +253,112 @@ function Settings() {
},
]
const tabItems = [
{
key: 'system',
label: '系统显示',
children: (
<SettingsPanel 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>
</SettingsPanel>
),
},
{
key: 'notifications',
label: '通知策略',
children: (
<SettingsPanel 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>
</SettingsPanel>
),
},
{
key: 'security',
label: '安全策略',
children: (
<SettingsPanel 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>
</SettingsPanel>
),
},
{
key: 'collectors',
label: '采集调度',
children: (
<div className="settings-pane">
<Card
className="settings-panel-card settings-panel-card--table"
loading={loading}
styles={{ body: { padding: 0 } }}
>
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region">
<Table
rowKey="id"
columns={collectorColumns}
dataSource={collectors}
pagination={false}
scroll={{ x: 1200, y: collectorTableHeight }}
tableLayout="fixed"
size="small"
/>
</div>
</Card>
</div>
),
},
]
return (
<AppLayout>
<div className="page-shell settings-shell">
@@ -248,129 +370,7 @@ function Settings() {
</div>
<div className="page-shell__body settings-tabs-shell">
<Tabs
className="settings-tabs"
items={[
{
key: 'system',
label: '系统显示',
children: (
<div className="settings-tab-panel">
<Card className="settings-panel-card" loading={loading}>
<div className="settings-panel-scroll">
<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>
</div>
</Card>
</div>
),
},
{
key: 'notifications',
label: '通知策略',
children: (
<div className="settings-tab-panel">
<Card className="settings-panel-card" loading={loading}>
<div className="settings-panel-scroll">
<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>
</div>
</Card>
</div>
),
},
{
key: 'security',
label: '安全策略',
children: (
<div className="settings-tab-panel">
<Card className="settings-panel-card" loading={loading}>
<div className="settings-panel-scroll">
<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>
</div>
</Card>
</div>
),
},
{
key: 'collectors',
label: '采集调度',
children: (
<div className="settings-tab-panel">
<Card
className="settings-panel-card settings-panel-card--table"
loading={loading}
bodyStyle={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<div
ref={collectorTableRegionRef}
className="table-scroll-region data-source-table-region settings-table-scroll-region"
style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}
>
<Table
rowKey="id"
columns={collectorColumns}
dataSource={collectors}
pagination={false}
scroll={{ x: 1200, y: collectorTableHeight }}
virtual
/>
</div>
</Card>
</div>
),
},
]}
/>
<Tabs className="settings-tabs" items={tabItems} />
</div>
</div>
</AppLayout>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
import axios from 'axios'
@@ -18,6 +18,8 @@ function Users() {
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const tableRegionRef = useRef<HTMLDivElement | null>(null)
const [tableHeight, setTableHeight] = useState(360)
const [form] = Form.useForm()
const fetchUsers = async () => {
@@ -34,6 +36,24 @@ function Users() {
fetchUsers()
}, [])
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = tableRegionRef.current?.offsetHeight || 0
setTableHeight(Math.max(220, regionHeight - 56))
}
updateTableHeight()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateTableHeight)
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
return () => observer.disconnect()
}, [users.length])
const handleAdd = () => {
setEditingUser(null)
form.resetFields()
@@ -77,12 +97,13 @@ function Users() {
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 180 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 260, ellipsis: true },
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 140,
render: (role: string) => {
const colors: Record<string, string> = {
super_admin: 'red',
@@ -97,6 +118,7 @@ function Users() {
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 120,
render: (active: boolean) => (
<Tag color={active ? 'green' : 'red'}>{active ? '活跃' : '禁用'}</Tag>
),
@@ -104,6 +126,7 @@ function Users() {
{
title: '操作',
key: 'action',
width: 180,
render: (_: unknown, record: User) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
@@ -121,8 +144,15 @@ function Users() {
<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 ref={tableRegionRef} className="table-scroll-region data-source-table-region" style={{ height: '100%' }}>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content', y: tableHeight }}
tableLayout="fixed"
/>
</div>
</div>
</div>