diff --git a/VERSION b/VERSION index c0e0456c..5f2e0fed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.4-dev +0.21.5-dev diff --git a/backend/app/api/v1/collected_data.py b/backend/app/api/v1/collected_data.py index 2f203657..0a62783a 100644 --- a/backend/app/api/v1/collected_data.py +++ b/backend/app/api/v1/collected_data.py @@ -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 + ], } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3c7dc926..7d808047 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c72d1866..3bc60ddc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 34fdc31d..76f8c812 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/index.css b/frontend/src/index.css index 9ecd85e0..8dbd5bed 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/pages/Dashboard/Dashboard.tsx b/frontend/src/pages/Dashboard/Dashboard.tsx index f1709de1..07df65b5 100644 --- a/frontend/src/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/pages/Dashboard/Dashboard.tsx @@ -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() { + + + +
+ 快捷入口 + 快速访问地球可视化页面 +
+ + + +
+
+ } /> diff --git a/frontend/src/pages/DataList/DataList.tsx b/frontend/src/pages/DataList/DataList.tsx index 5c380db2..afb1d3d4 100644 --- a/frontend/src/pages/DataList/DataList.tsx +++ b/frontend/src/pages/DataList/DataList.tsx @@ -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> 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([]) const [loading, setLoading] = useState(false) @@ -271,6 +275,7 @@ function DataList() { const [detailVisible, setDetailVisible] = useState(false) const [detailData, setDetailData] = useState(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] || } + const getDataTypeIcon = (dataType: string) => { + const iconMap: Record = { + supercomputer: , + gpu_cluster: , + model: , + dataset: , + space: , + submarine_cable: , + cable_landing_point: , + cable_landing_relation: , + ixp: , + network: , + facility: , + generic: , + } + + return iconMap[dataType] || + } + const getSourceTagColor = (source: string) => { const colorMap: Record = { top500: 'geekblue', @@ -504,72 +537,69 @@ function DataList() { ) } - const getTypeColor = (type: string) => { - const colors: Record = { - 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: }, + const summaryKpis = useMemo( + () => [ + { key: 'total', label: '总记录', value: summary?.overall_total_records || 0, icon: }, { key: 'result', label: '筛选结果', value: total, icon: }, { key: 'filters', label: '启用筛选', value: activeFilterCount, icon: }, - { key: 'sources', label: '数据源数', value: sources.length, icon: }, - ] + { + key: 'coverage', + label: treemapDimension === 'source' ? '覆盖数据源' : '覆盖类型', + value: treemapDimension === 'source' + ? summary?.source_totals?.length || 0 + : summary?.type_totals?.length || 0, + icon: treemapDimension === 'source' ? : , + }, + ], + [summary, total, activeFilterCount, treemapDimension] + ) - for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) { - items.push({ - key: item.source, - label: item.source_name, + 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: getSourceIcon(item.source), - }) + icon: getDataTypeIcon(item.data_type), + })) } - return items - }, [summary, total, activeFilterCount, isCompact, sources.length]) + return summary.source_totals.map((item) => ({ + key: item.source, + label: item.source_name, + value: item.count, + icon: getSourceIcon(item.source), + })) + }, [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 } }} >
+
+ {summaryKpis.map((item) => ( +
+
+ {item.icon} + {item.label} +
+ + {item.value.toLocaleString()} + +
+ ))} +
+
+ 分布概览 + setTreemapDimension(value as 'source' | 'type')} + options={[ + { label: '按数据源', value: 'source' }, + { label: '按类型', value: 'type' }, + ]} + /> +
- {treemapItems.map((item) => ( + {treemapItems.length > 0 ? treemapItems.map((item) => (
- ))} + )) : ( +
+ +
+ )}
diff --git a/frontend/src/pages/Users/Users.tsx b/frontend/src/pages/Users/Users.tsx index ea3b6e61..71d35e7d 100644 --- a/frontend/src/pages/Users/Users.tsx +++ b/frontend/src/pages/Users/Users.tsx @@ -144,7 +144,7 @@ function Users() {
-
+