1048 lines
35 KiB
TypeScript
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
|