feat: refine collected data overview and admin navigation
This commit is contained in:
@@ -214,21 +214,35 @@ async def list_collected_data(
|
|||||||
@router.get("/summary")
|
@router.get("/summary")
|
||||||
async def get_data_summary(
|
async def get_data_summary(
|
||||||
mode: str = Query("current", description="查询模式: current/history"),
|
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),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
# By source and data_type
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("""
|
text(f"""
|
||||||
SELECT source, data_type, COUNT(*) as count
|
SELECT source, data_type, COUNT(*) as count
|
||||||
FROM collected_data
|
FROM collected_data
|
||||||
""" + where_sql + """
|
WHERE {where_sql}
|
||||||
GROUP BY source, data_type
|
GROUP BY source, data_type
|
||||||
ORDER BY source, data_type
|
ORDER BY source, data_type
|
||||||
""")
|
"""),
|
||||||
|
params,
|
||||||
)
|
)
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
source_name_map = await get_source_name_map(db)
|
source_name_map = await get_source_name_map(db)
|
||||||
@@ -248,18 +262,32 @@ async def get_data_summary(
|
|||||||
|
|
||||||
# Total by source
|
# Total by source
|
||||||
source_totals = await db.execute(
|
source_totals = await db.execute(
|
||||||
text("""
|
text(f"""
|
||||||
SELECT source, COUNT(*) as count
|
SELECT source, COUNT(*) as count
|
||||||
FROM collected_data
|
FROM collected_data
|
||||||
""" + where_sql + """
|
WHERE {where_sql}
|
||||||
GROUP BY source
|
GROUP BY source
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
""")
|
"""),
|
||||||
|
params,
|
||||||
)
|
)
|
||||||
source_rows = source_totals.fetchall()
|
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 {
|
return {
|
||||||
"total_records": total,
|
"total_records": total,
|
||||||
|
"overall_total_records": overall_total,
|
||||||
"by_source": by_source,
|
"by_source": by_source,
|
||||||
"source_totals": [
|
"source_totals": [
|
||||||
{
|
{
|
||||||
@@ -269,6 +297,13 @@ async def get_data_summary(
|
|||||||
}
|
}
|
||||||
for row in source_rows
|
for row in source_rows
|
||||||
],
|
],
|
||||||
|
"type_totals": [
|
||||||
|
{
|
||||||
|
"data_type": row[0],
|
||||||
|
"count": row[1],
|
||||||
|
}
|
||||||
|
for row in type_rows
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ This project follows the repository versioning rule:
|
|||||||
- `feature` -> `+0.1.0`
|
- `feature` -> `+0.1.0`
|
||||||
- `bugfix` -> `+0.0.1`
|
- `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
|
## 0.21.3
|
||||||
|
|
||||||
Released: 2026-03-27
|
Released: 2026-03-27
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "1.0.0",
|
"version": "0.21.5-dev",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "1.0.0",
|
"version": "0.21.5-dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
"antd": "^5.12.5",
|
"antd": "^5.12.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.21.4-dev",
|
"version": "0.21.5-dev",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ body {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -320,6 +319,10 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.users-table-region .ant-table-body {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
.data-source-table-region .ant-table-wrapper,
|
.data-source-table-region .ant-table-wrapper,
|
||||||
.data-source-table-region .ant-spin-nested-loading,
|
.data-source-table-region .ant-spin-nested-loading,
|
||||||
.data-source-table-region .ant-spin-container {
|
.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-body::-webkit-scrollbar,
|
||||||
.table-scroll-region .ant-table-content::-webkit-scrollbar {
|
.table-scroll-region .ant-table-content::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 8px;
|
||||||
height: 10px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb,
|
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb,
|
||||||
@@ -439,6 +442,32 @@ body {
|
|||||||
background: transparent;
|
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-shell,
|
||||||
.settings-tabs-shell,
|
.settings-tabs-shell,
|
||||||
.settings-tabs,
|
.settings-tabs,
|
||||||
@@ -591,6 +620,8 @@ body {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-list-summary-card .ant-card-head,
|
.data-list-summary-card .ant-card-head,
|
||||||
@@ -604,6 +635,39 @@ body {
|
|||||||
|
|
||||||
.data-list-summary-card-inner {
|
.data-list-summary-card-inner {
|
||||||
min-height: 100%;
|
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 {
|
.data-list-right-column {
|
||||||
@@ -719,12 +783,24 @@ body {
|
|||||||
color: rgba(15, 23, 42, 0.72) !important;
|
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 {
|
.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 {
|
.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-radius: 999px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
@@ -927,6 +1003,13 @@ body {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-list-controls-shell {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
.data-list-topbar {
|
.data-list-topbar {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -945,11 +1028,22 @@ body {
|
|||||||
height: auto;
|
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 {
|
.data-list-summary-treemap {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
grid-auto-rows: minmax(88px, 1fr);
|
grid-auto-rows: minmax(88px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-list-summary-kpis {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.data-list-filter-grid {
|
.data-list-filter-grid {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -974,6 +1068,11 @@ body {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-list-summary-section-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-list-detail-modal {
|
.data-list-detail-modal {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
AlertOutlined,
|
AlertOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
WifiOutlined,
|
WifiOutlined,
|
||||||
DisconnectOutlined,
|
DisconnectOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
@@ -169,6 +171,21 @@ function Dashboard() {
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<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}>
|
<Col xs={24} md={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
|
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||||
import {
|
import {
|
||||||
Table, Tag, Space, Card, Select, Input, Button,
|
Table, Tag, Space, Card, Select, Input, Button, Segmented,
|
||||||
Modal, Spin, Empty, Tooltip, Typography, Grid
|
Modal, Spin, Empty, Tooltip, Typography, Grid
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
|
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
|
||||||
import {
|
import {
|
||||||
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
|
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
|
||||||
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
|
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined,
|
||||||
|
ApartmentOutlined, EnvironmentOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
@@ -43,8 +44,10 @@ interface CollectedData {
|
|||||||
|
|
||||||
interface Summary {
|
interface Summary {
|
||||||
total_records: number
|
total_records: number
|
||||||
|
overall_total_records: number
|
||||||
by_source: Record<string, Record<string, number>>
|
by_source: Record<string, Record<string, number>>
|
||||||
source_totals: Array<{ source: string; source_name: string; count: number }>
|
source_totals: Array<{ source: string; source_name: string; count: number }>
|
||||||
|
type_totals: Array<{ data_type: string; count: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SourceOption {
|
interface SourceOption {
|
||||||
@@ -256,6 +259,7 @@ function DataList() {
|
|||||||
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
|
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
|
||||||
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
|
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
|
||||||
const [summaryBodyHeight, setSummaryBodyHeight] = useState(0)
|
const [summaryBodyHeight, setSummaryBodyHeight] = useState(0)
|
||||||
|
const [summaryBodyWidth, setSummaryBodyWidth] = useState(0)
|
||||||
|
|
||||||
const [data, setData] = useState<CollectedData[]>([])
|
const [data, setData] = useState<CollectedData[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -271,6 +275,7 @@ function DataList() {
|
|||||||
const [detailVisible, setDetailVisible] = useState(false)
|
const [detailVisible, setDetailVisible] = useState(false)
|
||||||
const [detailData, setDetailData] = useState<CollectedData | null>(null)
|
const [detailData, setDetailData] = useState<CollectedData | null>(null)
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [treemapDimension, setTreemapDimension] = useState<'source' | 'type'>('source')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateLayout = () => {
|
const updateLayout = () => {
|
||||||
@@ -279,6 +284,7 @@ function DataList() {
|
|||||||
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
|
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
|
||||||
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
|
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
|
||||||
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
|
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
|
||||||
|
setSummaryBodyWidth(summaryBodyRef.current?.offsetWidth || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLayout()
|
updateLayout()
|
||||||
@@ -306,7 +312,7 @@ function DataList() {
|
|||||||
const minLeft = 260
|
const minLeft = 260
|
||||||
const minRight = 360
|
const minRight = 360
|
||||||
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
|
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) => {
|
setLeftPanelWidth((current) => {
|
||||||
if (!hasCustomLeftWidthRef.current) {
|
if (!hasCustomLeftWidthRef.current) {
|
||||||
@@ -364,7 +370,13 @@ function DataList() {
|
|||||||
|
|
||||||
const fetchSummary = async () => {
|
const fetchSummary = async () => {
|
||||||
try {
|
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)
|
setSummary(res.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch summary:', error)
|
console.error('Failed to fetch summary:', error)
|
||||||
@@ -385,17 +397,18 @@ function DataList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSummary()
|
|
||||||
fetchFilters()
|
fetchFilters()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
fetchSummary()
|
||||||
}, [page, pageSize, sourceFilter, typeFilter])
|
}, [page, pageSize, sourceFilter, typeFilter])
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
fetchData()
|
fetchData()
|
||||||
|
fetchSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -404,6 +417,7 @@ function DataList() {
|
|||||||
setSearchText('')
|
setSearchText('')
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setTimeout(fetchData, 0)
|
setTimeout(fetchData, 0)
|
||||||
|
setTimeout(fetchSummary, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleViewDetail = async (id: number) => {
|
const handleViewDetail = async (id: number) => {
|
||||||
@@ -431,6 +445,25 @@ function DataList() {
|
|||||||
return iconMap[source] || <DatabaseOutlined />
|
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 getSourceTagColor = (source: string) => {
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, string> = {
|
||||||
top500: 'geekblue',
|
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(
|
const activeFilterCount = useMemo(
|
||||||
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
|
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
|
||||||
[sourceFilter, typeFilter, searchText]
|
[sourceFilter, typeFilter, searchText]
|
||||||
)
|
)
|
||||||
|
|
||||||
const summaryItems = useMemo(() => {
|
const summaryKpis = useMemo(
|
||||||
const items = [
|
() => [
|
||||||
{ key: 'total', label: '总记录', value: summary?.total_records || 0, icon: <DatabaseOutlined /> },
|
{ key: 'total', label: '总记录', value: summary?.overall_total_records || 0, icon: <DatabaseOutlined /> },
|
||||||
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
|
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
|
||||||
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: <FilterOutlined /> },
|
{ 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)) {
|
const distributionItems = useMemo(() => {
|
||||||
items.push({
|
if (!summary) return []
|
||||||
key: item.source,
|
|
||||||
label: item.source_name,
|
if (treemapDimension === 'type') {
|
||||||
|
return summary.type_totals.map((item) => ({
|
||||||
|
key: item.data_type,
|
||||||
|
label: item.data_type,
|
||||||
value: item.count,
|
value: item.count,
|
||||||
icon: getSourceIcon(item.source),
|
icon: getDataTypeIcon(item.data_type),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return summary.source_totals.map((item) => ({
|
||||||
}, [summary, total, activeFilterCount, isCompact, sources.length])
|
key: item.source,
|
||||||
|
label: item.source_name,
|
||||||
|
value: item.count,
|
||||||
|
icon: getSourceIcon(item.source),
|
||||||
|
}))
|
||||||
|
}, [summary, treemapDimension])
|
||||||
|
|
||||||
const treemapColumns = useMemo(() => {
|
const treemapColumns = useMemo(() => {
|
||||||
if (isCompact) return 1
|
if (isCompact) return summaryBodyWidth >= 320 ? 2 : 1
|
||||||
if (leftPanelWidth < 360) return 2
|
if (leftPanelWidth < 360) return 2
|
||||||
if (leftPanelWidth < 520) return 3
|
if (leftPanelWidth < 520) return 3
|
||||||
return 4
|
return 4
|
||||||
}, [isCompact, leftPanelWidth])
|
}, [isCompact, leftPanelWidth, summaryBodyWidth])
|
||||||
|
|
||||||
const treemapItems = useMemo(() => {
|
const treemapItems = useMemo(() => {
|
||||||
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
|
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
|
||||||
const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
|
const limitedItems = distributionItems.slice(0, isCompact ? 6 : 10)
|
||||||
const allowFeaturedTile = !isCompact && treemapColumns > 1 && summaryItems.length > 2
|
const maxValue = Math.max(...limitedItems.map((item) => item.value), 1)
|
||||||
const allowSecondaryTallTiles = !isCompact && leftPanelWidth >= 520
|
const allowFeaturedTile = !isCompact && treemapColumns > 1 && limitedItems.length > 2
|
||||||
|
|
||||||
return summaryItems.map((item, index) => {
|
return limitedItems.map((item, index) => {
|
||||||
const ratio = item.value / maxValue
|
const ratio = item.value / maxValue
|
||||||
let colSpan = 1
|
let colSpan = 1
|
||||||
let rowSpan = 1
|
let rowSpan = 1
|
||||||
|
|
||||||
if (allowFeaturedTile && index === 0) {
|
if (allowFeaturedTile && index === 0 && ratio >= 0.35) {
|
||||||
colSpan = Math.min(2, treemapColumns)
|
colSpan = Math.min(2, treemapColumns)
|
||||||
rowSpan = 2
|
rowSpan = colSpan
|
||||||
} else if (allowSecondaryTallTiles && ratio >= 0.7) {
|
|
||||||
colSpan = Math.min(2, treemapColumns)
|
|
||||||
rowSpan = 2
|
|
||||||
} else if (allowSecondaryTallTiles && ratio >= 0.35) {
|
|
||||||
rowSpan = 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -579,7 +609,7 @@ function DataList() {
|
|||||||
tone: palette[index % palette.length],
|
tone: palette[index % palette.length],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
|
}, [distributionItems, isCompact, leftPanelWidth, treemapColumns])
|
||||||
|
|
||||||
const treemapRows = useMemo(
|
const treemapRows = useMemo(
|
||||||
() => estimateTreemapRows(treemapItems, treemapColumns),
|
() => estimateTreemapRows(treemapItems, treemapColumns),
|
||||||
@@ -587,16 +617,15 @@ function DataList() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const treemapGap = isCompact ? 8 : 10
|
const treemapGap = isCompact ? 8 : 10
|
||||||
const treemapMinRowHeight = isCompact ? 88 : 68
|
const treemapBaseSize = Math.max(
|
||||||
const treemapTargetRowHeight = isCompact ? 88 : leftPanelWidth < 360 ? 44 : leftPanelWidth < 520 ? 48 : 56
|
isCompact ? 88 : 68,
|
||||||
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
|
Math.min(
|
||||||
const treemapAutoRowHeight = treemapRows > 0
|
isCompact ? 220 : 180,
|
||||||
? Math.floor((treemapAvailableHeight - Math.max(0, treemapRows - 1) * treemapGap) / treemapRows)
|
Math.floor((Math.max(summaryBodyWidth, 0) - Math.max(0, treemapColumns - 1) * treemapGap) / treemapColumns)
|
||||||
: treemapTargetRowHeight
|
) || (isCompact ? 88 : 68)
|
||||||
const treemapRowHeight = Math.max(
|
|
||||||
treemapMinRowHeight,
|
|
||||||
Math.min(treemapTargetRowHeight, treemapAutoRowHeight || treemapTargetRowHeight)
|
|
||||||
)
|
)
|
||||||
|
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
|
||||||
|
const treemapRowHeight = treemapBaseSize
|
||||||
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
|
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
|
||||||
const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12
|
const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12
|
||||||
const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12
|
const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12
|
||||||
@@ -731,6 +760,31 @@ function DataList() {
|
|||||||
styles={{ body: { padding: isCompact ? 12 : 16 } }}
|
styles={{ body: { padding: isCompact ? 12 : 16 } }}
|
||||||
>
|
>
|
||||||
<div ref={summaryBodyRef} className="data-list-summary-card-inner">
|
<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
|
<div
|
||||||
className="data-list-summary-treemap"
|
className="data-list-summary-treemap"
|
||||||
style={{
|
style={{
|
||||||
@@ -744,7 +798,7 @@ function DataList() {
|
|||||||
['--data-list-treemap-value-size' as '--data-list-treemap-value-size']: `${treemapValueSize}px`,
|
['--data-list-treemap-value-size' as '--data-list-treemap-value-size']: `${treemapValueSize}px`,
|
||||||
} as CSSProperties}
|
} as CSSProperties}
|
||||||
>
|
>
|
||||||
{treemapItems.map((item) => (
|
{treemapItems.length > 0 ? treemapItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
|
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
|
||||||
@@ -763,7 +817,11 @@ function DataList() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)) : (
|
||||||
|
<div className="data-list-summary-empty">
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无分布数据" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ function Users() {
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-shell__body">
|
<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
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={users}
|
dataSource={users}
|
||||||
|
|||||||
Reference in New Issue
Block a user