Refine data management and collection workflows
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="暂无数据" />
|
||||
)}
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user