feat: refine collected data overview and admin navigation

This commit is contained in:
linkong
2026-03-27 15:08:45 +08:00
parent a761dfc5fb
commit 3dd210a3e5
9 changed files with 310 additions and 75 deletions

View File

@@ -1 +1 @@
0.21.4-dev
0.21.5-dev

View File

@@ -214,21 +214,35 @@ async def list_collected_data(
@router.get("/summary")
async def get_data_summary(
mode: str = Query("current", description="查询模式: current/history"),
source: Optional[str] = Query(None, description="数据源过滤"),
data_type: Optional[str] = Query(None, description="数据类型过滤"),
country: Optional[str] = Query(None, description="国家过滤"),
search: Optional[str] = Query(None, description="搜索名称"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取数据汇总统计"""
where_sql = "WHERE COALESCE(is_current, TRUE) = TRUE" if mode != "history" else ""
where_sql, params = build_where_clause(source, data_type, country, search)
if mode != "history":
where_sql = f"({where_sql}) AND COALESCE(is_current, TRUE) = TRUE"
overall_where_sql = "COALESCE(is_current, TRUE) = TRUE" if mode != "history" else "1=1"
overall_total_result = await db.execute(
text(f"SELECT COUNT(*) FROM collected_data WHERE {overall_where_sql}")
)
overall_total = overall_total_result.scalar() or 0
# By source and data_type
result = await db.execute(
text("""
text(f"""
SELECT source, data_type, COUNT(*) as count
FROM collected_data
""" + where_sql + """
WHERE {where_sql}
GROUP BY source, data_type
ORDER BY source, data_type
""")
"""),
params,
)
rows = result.fetchall()
source_name_map = await get_source_name_map(db)
@@ -248,18 +262,32 @@ async def get_data_summary(
# Total by source
source_totals = await db.execute(
text("""
text(f"""
SELECT source, COUNT(*) as count
FROM collected_data
""" + where_sql + """
WHERE {where_sql}
GROUP BY source
ORDER BY count DESC
""")
"""),
params,
)
source_rows = source_totals.fetchall()
type_totals = await db.execute(
text(f"""
SELECT data_type, COUNT(*) as count
FROM collected_data
WHERE {where_sql}
GROUP BY data_type
ORDER BY count DESC, data_type
"""),
params,
)
type_rows = type_totals.fetchall()
return {
"total_records": total,
"overall_total_records": overall_total,
"by_source": by_source,
"source_totals": [
{
@@ -269,6 +297,13 @@ async def get_data_summary(
}
for row in source_rows
],
"type_totals": [
{
"data_type": row[0],
"count": row[1],
}
for row in type_rows
],
}

View File

@@ -7,6 +7,32 @@ This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1`
## 0.21.5
Released: 2026-03-27
### Highlights
- Reworked the collected-data overview into a clearer split between KPI cards and a switchable treemap distribution.
- Added a direct Earth entry on the dashboard and tightened several admin-side scrolling/layout behaviors.
### Added
- Added a dashboard quick-access card linking directly to `/earth`.
- Added collected-data treemap switching between `按数据源` and `按类型`.
- Added data-type-specific icons for the collected-data overview treemap.
### Improved
- Improved collected-data summary behavior so overview counts follow the active filters and search state.
- Improved the collected-data treemap with square tiles, a wider default overview panel width, and narrower overview scrollbars.
- Improved responsive behavior near the tablet breakpoint so the collected-data page can scroll instead of clipping the overview or crushing the table.
### Fixed
- Fixed the user-management table overflow issue by restoring `ant-table-body` to auto height for that page so the outer container no longer incorrectly takes over vertical scrolling.
- Fixed overly wide scrollbar presentation in collected-data and related admin surfaces by aligning them with the slimmer in-app scrollbar style.
## 0.21.3
Released: 2026-03-27

View File

@@ -1,12 +1,12 @@
{
"name": "planet-frontend",
"version": "1.0.0",
"version": "0.21.5-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planet-frontend",
"version": "1.0.0",
"version": "0.21.5-dev",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"antd": "^5.12.5",

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.21.4-dev",
"version": "0.21.5-dev",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -173,7 +173,6 @@ body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -320,6 +319,10 @@ body {
min-height: 0;
}
.users-table-region .ant-table-body {
height: auto !important;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container {
@@ -416,8 +419,8 @@ body {
.table-scroll-region .ant-table-body::-webkit-scrollbar,
.table-scroll-region .ant-table-content::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
}
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb,
@@ -439,6 +442,32 @@ body {
background: transparent;
}
.data-list-controls-shell {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
}
.data-list-controls-shell::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.data-list-controls-shell::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.data-list-controls-shell::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
background-clip: padding-box;
}
.data-list-controls-shell::-webkit-scrollbar-track {
background: transparent;
}
.settings-shell,
.settings-tabs-shell,
.settings-tabs,
@@ -591,6 +620,8 @@ body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
}
.data-list-summary-card .ant-card-head,
@@ -604,6 +635,39 @@ body {
.data-list-summary-card-inner {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.data-list-summary-kpis {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.data-list-summary-kpi {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
border-radius: 14px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
border: 1px solid rgba(148, 163, 184, 0.22);
}
.data-list-summary-kpi__head,
.data-list-summary-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.data-list-summary-section-head {
margin-top: 4px;
}
.data-list-right-column {
@@ -719,12 +783,24 @@ body {
color: rgba(15, 23, 42, 0.72) !important;
}
.data-list-summary-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
min-height: 140px;
border-radius: 14px;
background: rgba(248, 250, 252, 0.8);
border: 1px dashed rgba(148, 163, 184, 0.35);
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar {
width: 10px;
width: 8px;
height: 8px;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.8);
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
@@ -927,6 +1003,13 @@ body {
gap: 10px;
}
.data-list-controls-shell {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
scrollbar-gutter: stable;
}
.data-list-topbar {
align-items: flex-start;
flex-direction: column;
@@ -945,11 +1028,22 @@ body {
height: auto;
}
.data-list-summary-card--panel,
.data-list-summary-card--panel .ant-card-body,
.data-list-table-shell,
.data-list-table-shell .ant-card-body {
height: auto;
}
.data-list-summary-treemap {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(88px, 1fr);
}
.data-list-summary-kpis {
grid-template-columns: 1fr;
}
.data-list-filter-grid {
flex-wrap: wrap;
}
@@ -974,6 +1068,11 @@ body {
min-width: 100%;
}
.data-list-summary-section-head {
align-items: flex-start;
flex-direction: column;
}
}
.data-list-detail-modal {

View File

@@ -4,10 +4,12 @@ import {
DatabaseOutlined,
BarChartOutlined,
AlertOutlined,
GlobalOutlined,
WifiOutlined,
DisconnectOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
@@ -169,6 +171,21 @@ function Dashboard() {
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div>
<Title level={5} style={{ margin: 0 }}></Title>
<Text type="secondary">访</Text>
</div>
<Link to="/earth">
<Button type="primary" icon={<GlobalOutlined />}>
访 Earth
</Button>
</Link>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />

View File

@@ -1,13 +1,14 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
Table, Tag, Space, Card, Select, Input, Button,
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
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined,
ApartmentOutlined, EnvironmentOutlined
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
@@ -43,8 +44,10 @@ interface CollectedData {
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 {
@@ -256,6 +259,7 @@ function DataList() {
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)
@@ -271,6 +275,7 @@ function DataList() {
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 = () => {
@@ -279,6 +284,7 @@ function DataList() {
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
setSummaryBodyWidth(summaryBodyRef.current?.offsetWidth || 0)
}
updateLayout()
@@ -306,7 +312,7 @@ function DataList() {
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft))
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 3), maxLeft))
setLeftPanelWidth((current) => {
if (!hasCustomLeftWidthRef.current) {
@@ -364,7 +370,13 @@ function DataList() {
const fetchSummary = async () => {
try {
const res = await axios.get('/api/v1/collected/summary')
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)
@@ -385,17 +397,18 @@ function DataList() {
}
useEffect(() => {
fetchSummary()
fetchFilters()
}, [])
useEffect(() => {
fetchData()
fetchSummary()
}, [page, pageSize, sourceFilter, typeFilter])
const handleSearch = () => {
setPage(1)
fetchData()
fetchSummary()
}
const handleReset = () => {
@@ -404,6 +417,7 @@ function DataList() {
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
setTimeout(fetchSummary, 0)
}
const handleViewDetail = async (id: number) => {
@@ -431,6 +445,25 @@ function DataList() {
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',
@@ -504,72 +537,69 @@ function DataList() {
)
}
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
supercomputer: 'red',
model: 'blue',
dataset: 'green',
space: 'purple',
submarine_cable: 'cyan',
gpu_cluster: 'orange',
ixp: 'magenta',
network: 'gold',
facility: 'lime',
}
return colors[type] || 'default'
}
const activeFilterCount = useMemo(
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, searchText]
)
const summaryItems = useMemo(() => {
const items = [
{ key: 'total', label: '总记录', value: summary?.total_records || 0, icon: <DatabaseOutlined /> },
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: 'sources', label: '数据源数', value: sources.length, icon: <DatabaseOutlined /> },
]
{
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]
)
for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
items.push({
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),
})
}
return items
}, [summary, total, activeFilterCount, isCompact, sources.length])
}))
}, [summary, treemapDimension])
const treemapColumns = useMemo(() => {
if (isCompact) return 1
if (isCompact) return summaryBodyWidth >= 320 ? 2 : 1
if (leftPanelWidth < 360) return 2
if (leftPanelWidth < 520) return 3
return 4
}, [isCompact, leftPanelWidth])
}, [isCompact, leftPanelWidth, summaryBodyWidth])
const treemapItems = useMemo(() => {
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
const allowFeaturedTile = !isCompact && treemapColumns > 1 && summaryItems.length > 2
const allowSecondaryTallTiles = !isCompact && leftPanelWidth >= 520
const limitedItems = distributionItems.slice(0, isCompact ? 6 : 10)
const maxValue = Math.max(...limitedItems.map((item) => item.value), 1)
const allowFeaturedTile = !isCompact && treemapColumns > 1 && limitedItems.length > 2
return summaryItems.map((item, index) => {
return limitedItems.map((item, index) => {
const ratio = item.value / maxValue
let colSpan = 1
let rowSpan = 1
if (allowFeaturedTile && index === 0) {
if (allowFeaturedTile && index === 0 && ratio >= 0.35) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowSecondaryTallTiles && ratio >= 0.7) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowSecondaryTallTiles && ratio >= 0.35) {
rowSpan = 2
rowSpan = colSpan
}
return {
@@ -579,7 +609,7 @@ function DataList() {
tone: palette[index % palette.length],
}
})
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
}, [distributionItems, isCompact, leftPanelWidth, treemapColumns])
const treemapRows = useMemo(
() => estimateTreemapRows(treemapItems, treemapColumns),
@@ -587,16 +617,15 @@ function DataList() {
)
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 treemapBaseSize = Math.max(
isCompact ? 88 : 68,
Math.min(
isCompact ? 220 : 180,
Math.floor((Math.max(summaryBodyWidth, 0) - Math.max(0, treemapColumns - 1) * treemapGap) / treemapColumns)
) || (isCompact ? 88 : 68)
)
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
const treemapRowHeight = treemapBaseSize
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
@@ -731,6 +760,31 @@ function DataList() {
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={{
@@ -744,7 +798,7 @@ function DataList() {
['--data-list-treemap-value-size' as '--data-list-treemap-value-size']: `${treemapValueSize}px`,
} as CSSProperties}
>
{treemapItems.map((item) => (
{treemapItems.length > 0 ? treemapItems.map((item) => (
<div
key={item.key}
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
@@ -763,7 +817,11 @@ function DataList() {
</Text>
</div>
</div>
))}
)) : (
<div className="data-list-summary-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无分布数据" />
</div>
)}
</div>
</div>
</Card>

View File

@@ -144,7 +144,7 @@ function Users() {
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
<div className="page-shell__body">
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region" style={{ height: '100%' }}>
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}>
<Table
columns={columns}
dataSource={users}