Files
planet/frontend/src/pages/DataList/DataList.tsx
2026-04-14 17:34:04 +08:00

1048 lines
35 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
Table, Tag, Space, Card, Select, Input, Button, Segmented,
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,
ApartmentOutlined, EnvironmentOutlined
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
const { Title, Text } = Typography
const { useBreakpoint } = Grid
interface CollectedData {
id: number
source: string
source_name: string
source_id: string
data_type: string
name: string
title: string | null
description: string | null
country: string | null
city: string | null
latitude: string | null
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
is_valid: number
}
interface Summary {
total_records: number
overall_total_records: number
by_source: Record<string, Record<string, number>>
source_totals: Array<{ source: string; source_name: string; count: number }>
type_totals: Array<{ data_type: string; count: number }>
}
interface SourceOption {
source: string
source_name: string
}
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 = parseBackendDate(String(value))
if (!date) {
return String(value)
}
return Number.isNaN(date.getTime())
? String(value)
: key === 'reference_date'
? formatDateZhCN(String(value))
: formatDateTimeZhCN(String(value))
}
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2)
}
return String(value)
}
function getDetailFieldValue(detailData: CollectedData, key: string): unknown {
if (key === 'source') {
return detailData.source_name || detailData.source
}
return detailData[key as keyof CollectedData]
}
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 getTreemapSpan(value: number, maxValue: number, columns: number) {
if (columns <= 1) return 1
const normalized = Math.log10(value + 1) / Math.log10(maxValue + 1)
if (columns >= 4 && normalized >= 0.94) return 3
if (normalized >= 0.62) return 2
return 1
}
function isCompactTreemapItem(item: { colSpan: number; rowSpan: number }) {
return item.colSpan === 1 && item.rowSpan === 1
}
function getTreemapColumnCount(
width: number,
minCellSize: number,
gap: number,
isCompact: boolean
) {
const visualCap = isCompact ? 4 : 8
if (width <= 0) return Math.min(visualCap, isCompact ? 2 : 4)
const maxColumnsByWidth = Math.max(1, Math.floor((width + gap) / (minCellSize + gap)))
return Math.max(1, Math.min(maxColumnsByWidth, visualCap))
}
function getTreemapBaseSize(width: number, columns: number, gap: number, minCellSize: number) {
const fittedSize = Math.floor((Math.max(width, 0) - Math.max(0, columns - 1) * gap) / columns)
return Math.max(minCellSize, fittedSize || minCellSize)
}
function getTreemapTypography(rowHeight: number) {
const tilePadding = rowHeight <= 72 ? 8 : rowHeight <= 84 ? 10 : 12
const labelSize = rowHeight <= 72 ? 10 : rowHeight <= 84 ? 11 : 12
const valueSize = rowHeight <= 72 ? 13 : rowHeight <= 84 ? 15 : 16
return { tilePadding, labelSize, valueSize }
}
function getTreemapItemValueSize(
item: { colSpan: number; rowSpan: number },
baseValueSize: number
) {
if (isCompactTreemapItem(item)) {
return Math.max(11, baseValueSize - 2)
}
return baseValueSize
}
function DataList() {
const screens = useBreakpoint()
const isCompact = !screens.lg
const topbarRef = useRef<HTMLDivElement | null>(null)
const workspaceRef = useRef<HTMLDivElement | null>(null)
const mainAreaRef = useRef<HTMLDivElement | null>(null)
const rightColumnRef = useRef<HTMLDivElement | null>(null)
const tableHeaderRef = useRef<HTMLDivElement | null>(null)
const summaryBodyRef = useRef<HTMLDivElement | null>(null)
const hasCustomLeftWidthRef = useRef(false)
const [mainAreaWidth, setMainAreaWidth] = useState(0)
const [mainAreaHeight, setMainAreaHeight] = useState(0)
const [rightColumnHeight, setRightColumnHeight] = useState(0)
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
const [summaryBodyHeight, setSummaryBodyHeight] = useState(0)
const [summaryBodyWidth, setSummaryBodyWidth] = useState(0)
const [data, setData] = useState<CollectedData[]>([])
const [loading, setLoading] = useState(false)
const [summary, setSummary] = useState<Summary | null>(null)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [sourceFilter, setSourceFilter] = useState<string[]>([])
const [typeFilter, setTypeFilter] = useState<string[]>([])
const [searchText, setSearchText] = useState('')
const [sources, setSources] = useState<SourceOption[]>([])
const [types, setTypes] = useState<string[]>([])
const [detailVisible, setDetailVisible] = useState(false)
const [detailData, setDetailData] = useState<CollectedData | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [treemapDimension, setTreemapDimension] = useState<'source' | 'type'>('source')
useEffect(() => {
const updateLayout = () => {
setMainAreaWidth(mainAreaRef.current?.offsetWidth || 0)
setMainAreaHeight(mainAreaRef.current?.offsetHeight || 0)
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
setSummaryBodyWidth(summaryBodyRef.current?.offsetWidth || 0)
}
updateLayout()
if (typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(updateLayout)
if (workspaceRef.current) observer.observe(workspaceRef.current)
if (topbarRef.current) observer.observe(topbarRef.current)
if (mainAreaRef.current) observer.observe(mainAreaRef.current)
if (rightColumnRef.current) observer.observe(rightColumnRef.current)
if (tableHeaderRef.current) observer.observe(tableHeaderRef.current)
if (summaryBodyRef.current) observer.observe(summaryBodyRef.current)
return () => observer.disconnect()
}, [isCompact])
useEffect(() => {
if (isCompact || mainAreaWidth === 0) {
return
}
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 3), maxLeft))
setLeftPanelWidth((current) => {
if (!hasCustomLeftWidthRef.current) {
return preferredLeft
}
return Math.max(minLeft, Math.min(current, maxLeft))
})
}, [isCompact, mainAreaWidth])
const beginHorizontalResize = (event: React.MouseEvent<HTMLDivElement>) => {
if (isCompact) return
event.preventDefault()
hasCustomLeftWidthRef.current = true
const startX = event.clientX
const startWidth = leftPanelWidth
const containerWidth = mainAreaRef.current?.offsetWidth || 0
const onMove = (moveEvent: MouseEvent) => {
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, containerWidth - minRight - 12)
const nextWidth = startWidth + moveEvent.clientX - startX
setLeftPanelWidth(Math.max(minLeft, Math.min(nextWidth, maxLeft)))
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const fetchData = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
})
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}`)
setData(res.data.data)
setTotal(res.data.total)
} catch (error) {
console.error('Failed to fetch data:', error)
} finally {
setLoading(false)
}
}
const fetchSummary = async () => {
try {
const params = new URLSearchParams()
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 query = params.toString()
const res = await axios.get(query ? `/api/v1/collected/summary?${query}` : '/api/v1/collected/summary')
setSummary(res.data)
} catch (error) {
console.error('Failed to fetch summary:', error)
}
}
const fetchFilters = async () => {
try {
const [sourcesRes, typesRes] = await Promise.all([
axios.get('/api/v1/collected/sources'),
axios.get('/api/v1/collected/types'),
])
setSources(sourcesRes.data.sources || [])
setTypes(typesRes.data.data_types || [])
} catch (error) {
console.error('Failed to fetch filters:', error)
}
}
useEffect(() => {
fetchFilters()
}, [])
useEffect(() => {
fetchData()
fetchSummary()
}, [page, pageSize, sourceFilter, typeFilter])
const handleSearch = () => {
setPage(1)
fetchData()
fetchSummary()
}
const handleReset = () => {
setSourceFilter([])
setTypeFilter([])
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
setTimeout(fetchSummary, 0)
}
const handleViewDetail = async (id: number) => {
setDetailVisible(true)
setDetailLoading(true)
try {
const res = await axios.get(`/api/v1/collected/${id}`)
setDetailData(res.data)
} catch (error) {
console.error('Failed to fetch detail:', error)
} finally {
setDetailLoading(false)
}
}
const getSourceIcon = (source: string) => {
const iconMap: Record<string, React.ReactNode> = {
top500: <CloudServerOutlined />,
huggingface_models: <AppstoreOutlined />,
huggingface_datasets: <DatabaseOutlined />,
huggingface_spaces: <AppstoreOutlined />,
telegeography_cables: <GlobalOutlined />,
epoch_ai_gpu: <CloudServerOutlined />,
}
return iconMap[source] || <DatabaseOutlined />
}
const getDataTypeIcon = (dataType: string) => {
const iconMap: Record<string, React.ReactNode> = {
supercomputer: <CloudServerOutlined />,
gpu_cluster: <CloudServerOutlined />,
model: <AppstoreOutlined />,
dataset: <DatabaseOutlined />,
space: <AppstoreOutlined />,
submarine_cable: <GlobalOutlined />,
cable_landing_point: <EnvironmentOutlined />,
cable_landing_relation: <GlobalOutlined />,
ixp: <ApartmentOutlined />,
network: <GlobalOutlined />,
facility: <ApartmentOutlined />,
generic: <DatabaseOutlined />,
}
return iconMap[dataType] || <DatabaseOutlined />
}
const getSourceTagColor = (source: string) => {
const colorMap: Record<string, string> = {
top500: 'geekblue',
huggingface_models: 'purple',
huggingface_datasets: 'cyan',
huggingface_spaces: 'magenta',
peeringdb_ixp: 'gold',
peeringdb_network: 'orange',
peeringdb_facility: 'lime',
telegeography_cables: 'green',
telegeography_landing: 'green',
telegeography_systems: 'emerald',
arcgis_cables: 'blue',
arcgis_landing_points: 'cyan',
arcgis_cable_landing_relations: 'volcano',
fao_landing_points: 'processing',
epoch_ai_gpu: 'volcano',
ris_live_bgp: 'red',
bgpstream_bgp: 'purple',
cloudflare_radar_device: 'magenta',
cloudflare_radar_traffic: 'orange',
cloudflare_radar_top_as: 'gold',
}
if (colorMap[source]) {
return colorMap[source]
}
const fallbackPalette = [
'blue',
'geekblue',
'cyan',
'green',
'lime',
'gold',
'orange',
'volcano',
'magenta',
'purple',
]
const hash = Array.from(source).reduce((acc, char) => acc + char.charCodeAt(0), 0)
return fallbackPalette[hash % fallbackPalette.length]
}
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 activeFilterCount = useMemo(
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, searchText]
)
const summaryKpis = useMemo(
() => [
{ key: 'total', label: '总记录', value: summary?.overall_total_records || 0, icon: <DatabaseOutlined /> },
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: <FilterOutlined /> },
{
key: 'coverage',
label: treemapDimension === 'source' ? '覆盖数据源' : '覆盖类型',
value: treemapDimension === 'source'
? summary?.source_totals?.length || 0
: summary?.type_totals?.length || 0,
icon: treemapDimension === 'source' ? <DatabaseOutlined /> : <AppstoreOutlined />,
},
],
[summary, total, activeFilterCount, treemapDimension]
)
const distributionItems = useMemo(() => {
if (!summary) return []
if (treemapDimension === 'type') {
return summary.type_totals.map((item) => ({
key: item.data_type,
label: item.data_type,
value: item.count,
icon: getDataTypeIcon(item.data_type),
}))
}
return summary.source_totals.map((item) => ({
key: item.source,
label: item.source_name,
value: item.count,
icon: getSourceIcon(item.source),
}))
}, [summary, treemapDimension])
const treemapGap = isCompact ? 8 : 10
const treemapMinCellSize = isCompact ? 72 : 52
const treemapColumns = useMemo(() => {
return getTreemapColumnCount(summaryBodyWidth, treemapMinCellSize, treemapGap, isCompact)
}, [isCompact, summaryBodyWidth, treemapGap, treemapMinCellSize])
const treemapItems = useMemo(() => {
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
const maxItems = isCompact ? 6 : 10
const limitedItems = distributionItems.slice(0, maxItems)
const maxValue = Math.max(...limitedItems.map((item) => item.value), 1)
return limitedItems.map((item, index) => {
const span = Math.min(getTreemapSpan(item.value, maxValue, treemapColumns), treemapColumns)
return {
...item,
colSpan: span,
rowSpan: span,
tone: palette[index % palette.length],
}
})
}, [distributionItems, isCompact, treemapColumns])
const treemapRows = useMemo(
() => estimateTreemapRows(treemapItems, treemapColumns),
[treemapColumns, treemapItems]
)
const treemapBaseSize = Math.max(
treemapMinCellSize,
getTreemapBaseSize(summaryBodyWidth, treemapColumns, treemapGap, treemapMinCellSize)
)
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
const treemapRowHeight = treemapBaseSize
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
const { tilePadding: treemapTilePadding, labelSize: treemapLabelSize, valueSize: treemapValueSize } =
getTreemapTypography(treemapRowHeight)
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, getDetailFieldValue(detailData, key)),
})).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: '名称',
dataIndex: 'name',
key: 'name',
width: 320,
ellipsis: true,
render: (name: string, record: CollectedData) => (
<Tooltip title={name}>
<Button type="link" className="data-list-name-link" onClick={() => handleViewDetail(record.id)}>
<NameMarquee text={name} />
</Button>
</Tooltip>
),
},
{
title: '数据源',
dataIndex: 'source',
key: 'source',
minWidth: 140,
render: (_: string, record: CollectedData) => (
record.source ? (
<div className="data-list-tag-cell">
<Tag color={getSourceTagColor(record.source)} style={{ marginInlineEnd: 0 }}>
{record.source_name || record.source}
</Tag>
</div>
) : '-'
),
},
{
title: '数据类型',
dataIndex: 'data_type',
key: 'data_type',
minWidth: 140,
render: (value: string) => (
value ? (
<div className="data-list-tag-cell">
<Tag color={getDataTypeTagColor(value)} style={{ marginInlineEnd: 0 }}>
{value}
</Tag>
</div>
) : '-'
),
},
{
title: '采集时间',
dataIndex: 'collected_at',
key: 'collected_at',
width: 180,
render: (time: string) => formatDateTimeZhCN(time),
},
{
title: '参考日期',
dataIndex: 'reference_date',
key: 'reference_date',
width: 120,
render: (time: string | null) => formatDateZhCN(time),
},
{
title: '操作',
key: 'action',
width: 96,
render: (_: unknown, record: CollectedData) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
</Button>
),
},
]
return (
<AppLayout>
<div ref={workspaceRef} className="data-list-workspace" style={{ height: pageHeight }}>
<div ref={topbarRef} className="data-list-topbar">
<div>
<Title level={4} style={{ margin: 0 }}></Title>
</div>
<Space size={8} wrap>
<Tag color="blue" style={{ marginInlineEnd: 0 }}>
{total.toLocaleString()}
</Tag>
<Tag color="default" style={{ marginInlineEnd: 0 }}>
{activeFilterCount}
</Tag>
</Space>
</div>
<div ref={mainAreaRef} className="data-list-controls-shell">
<div className="data-list-split-layout" style={splitLayoutStyle}>
<Card
className="data-list-summary-card data-list-summary-card--panel"
title="数据概览"
size="small"
styles={{ body: { padding: isCompact ? 12 : 16 } }}
>
<div ref={summaryBodyRef} className="data-list-summary-card-inner">
<div className="data-list-summary-kpis">
{summaryKpis.map((item) => (
<div key={item.key} className="data-list-summary-kpi">
<div className="data-list-summary-kpi__head">
<span className="data-list-summary-tile-icon">{item.icon}</span>
<Text className="data-list-treemap-label">{item.label}</Text>
</div>
<Text strong className="data-list-summary-tile-value">
{item.value.toLocaleString()}
</Text>
</div>
))}
</div>
<div className="data-list-summary-section-head">
<Text strong></Text>
<Segmented
size="small"
value={treemapDimension}
onChange={(value) => setTreemapDimension(value as 'source' | 'type')}
options={[
{ label: '按数据源', value: 'source' },
{ label: '按类型', value: 'type' },
]}
/>
</div>
<div
className="data-list-summary-treemap"
style={{
gridTemplateColumns: `repeat(${treemapColumns}, minmax(0, 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.length > 0 ? treemapItems.map((item) => (
<div
key={item.key}
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}${isCompactTreemapItem(item) ? ' data-list-treemap-tile--compact' : ''}`}
style={{
gridColumn: `span ${item.colSpan}`,
gridRow: `span ${item.rowSpan}`,
}}
>
<div className="data-list-treemap-head">
<Tooltip title={item.label}>
<span className="data-list-summary-tile-icon">{item.icon}</span>
</Tooltip>
{!isCompactTreemapItem(item) ? (
<Tooltip title={item.label}>
<Text className="data-list-treemap-label">{item.label}</Text>
</Tooltip>
) : null}
</div>
<div className="data-list-treemap-body">
<Text
strong
className="data-list-summary-tile-value"
style={{ fontSize: getTreemapItemValueSize(item, treemapValueSize) }}
>
{item.value.toLocaleString()}
</Text>
</div>
</div>
)) : (
<div className="data-list-summary-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无分布数据" />
</div>
)}
</div>
</div>
</Card>
{!isCompact && (
<div
className="data-list-resize-handle data-list-resize-handle--vertical"
onMouseDown={beginHorizontalResize}
role="separator"
aria-orientation="vertical"
aria-label="调整左右分栏宽度"
/>
)}
<div ref={rightColumnRef} className="data-list-right-column">
<Card className="data-list-table-shell" 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>
<Text strong></Text>
<Text type="secondary"> {total.toLocaleString()} </Text>
</Space>
<Space size={8} wrap>
<Button size="small" onClick={handleReset}></Button>
<Button size="small" icon={<ReloadOutlined />} onClick={fetchData}></Button>
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
</div>
<div className="data-list-filter-grid data-list-filter-grid--balanced data-list-filter-grid--header">
<Select
size="middle"
placeholder="数据源"
mode="multiple"
allowClear
value={sourceFilter}
onChange={(value) => {
setSourceFilter(value)
setPage(1)
}}
options={sources.map((source) => ({ label: source.source_name, value: source.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) => {
setTypeFilter(value)
setPage(1)
}}
options={types.map((type) => ({ label: type, value: type }))}
tagRender={(tagProps) => renderFilterTag(tagProps, getDataTypeTagColor)}
style={{ width: '100%' }}
className="data-list-filter-select"
/>
<Input
size="middle"
placeholder="搜索名称、描述、元数据等"
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
onPressEnter={handleSearch}
/>
</div>
</div>
<TableScrollRegion className="data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 'max-content', y: tableHeight }}
tableLayout="auto"
size={isCompact ? 'small' : 'middle'}
pagination={{
current: page,
pageSize,
total,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
showSizeChanger: true,
showTotal: (count) => `${count}`,
}}
/>
</TableScrollRegion>
</Card>
</div>
</div>
</div>
</div>
<Modal
title="数据详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailVisible(false)}>
</Button>,
]}
width={880}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : detailData ? (
<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>
</section>
</div>
) : (
<Empty description="暂无数据" />
)}
</Modal>
</AppLayout>
)
}
export default DataList