fix: 修复容量看板布局塌陷并调整 KPI/图表(定高不滚动、新增上行指标、流量单位自适应、制式排除未知)
This commit is contained in:
@@ -110,6 +110,7 @@ def dashboard_overview(rat: str = Query("4g")):
|
|||||||
f" SUM(`高负荷问题`='利用率预警') util_warn,"
|
f" SUM(`高负荷问题`='利用率预警') util_warn,"
|
||||||
f" SUM(`高负荷问题`='高流量预警') flow_warn,"
|
f" SUM(`高负荷问题`='高流量预警') flow_warn,"
|
||||||
f" SUM(`高负荷问题` IS NULL) normal,"
|
f" SUM(`高负荷问题` IS NULL) normal,"
|
||||||
|
f" ROUND(AVG({ul})*100,1) avg_ul,"
|
||||||
f" ROUND(AVG({dl})*100,1) avg_dl,"
|
f" ROUND(AVG({dl})*100,1) avg_dl,"
|
||||||
f" ROUND(MAX({dl})*100,1) max_dl,"
|
f" ROUND(MAX({dl})*100,1) max_dl,"
|
||||||
f" ROUND(AVG({flow}),2) avg_flow,"
|
f" ROUND(AVG({flow}),2) avg_flow,"
|
||||||
@@ -123,6 +124,7 @@ def dashboard_overview(rat: str = Query("4g")):
|
|||||||
"util_warn": _num(s.get("util_warn")),
|
"util_warn": _num(s.get("util_warn")),
|
||||||
"flow_warn": _num(s.get("flow_warn")),
|
"flow_warn": _num(s.get("flow_warn")),
|
||||||
"normal": _num(s.get("normal")),
|
"normal": _num(s.get("normal")),
|
||||||
|
"avg_ul": _num(s.get("avg_ul"), 1),
|
||||||
"avg_dl": _num(s.get("avg_dl"), 1),
|
"avg_dl": _num(s.get("avg_dl"), 1),
|
||||||
"max_dl": _num(s.get("max_dl"), 1),
|
"max_dl": _num(s.get("max_dl"), 1),
|
||||||
"avg_flow": _num(s.get("avg_flow"), 2),
|
"avg_flow": _num(s.get("avg_flow"), 2),
|
||||||
@@ -136,13 +138,14 @@ def dashboard_overview(rat: str = Query("4g")):
|
|||||||
{"name": "正常", "value": summary_out["normal"]},
|
{"name": "正常", "value": summary_out["normal"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
def group_by(col: str, limit: int = 0) -> list[dict[str, Any]]:
|
def group_by(col: str, limit: int = 0, skip_unknown: bool = False) -> list[dict[str, Any]]:
|
||||||
limit_sql = f" LIMIT {int(limit)}" if limit else ""
|
limit_sql = f" LIMIT {int(limit)}" if limit else ""
|
||||||
|
where_sql = f" WHERE `{col}` IS NOT NULL AND `{col}` <> ''" if skip_unknown else ""
|
||||||
rows = _rows(db, (
|
rows = _rows(db, (
|
||||||
f"SELECT IFNULL(`{col}`,'未知') name, COUNT(*) total,"
|
f"SELECT IFNULL(`{col}`,'未知') name, COUNT(*) total,"
|
||||||
f" SUM(`是否高负荷小区`='是') high,"
|
f" SUM(`是否高负荷小区`='是') high,"
|
||||||
f" SUM(`高负荷问题` IS NOT NULL) flagged"
|
f" SUM(`高负荷问题` IS NOT NULL) flagged"
|
||||||
f" FROM {t} GROUP BY `{col}` ORDER BY flagged DESC, total DESC{limit_sql}"
|
f" FROM {t}{where_sql} GROUP BY `{col}` ORDER BY flagged DESC, total DESC{limit_sql}"
|
||||||
))
|
))
|
||||||
return [
|
return [
|
||||||
{"name": str(r.get("name")), "total": _num(r.get("total")),
|
{"name": str(r.get("name")), "total": _num(r.get("total")),
|
||||||
@@ -150,15 +153,13 @@ def dashboard_overview(rat: str = Query("4g")):
|
|||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
# 下行利用率分布(0-10%...90-100%)
|
def util_hist(col: str) -> list[dict[str, Any]]:
|
||||||
hist_rows = _rows(db, (
|
rows = _rows(db, (
|
||||||
f"SELECT LEAST(FLOOR({dl}*10),9) b, COUNT(*) c FROM {t}"
|
f"SELECT LEAST(FLOOR({col}*10),9) b, COUNT(*) c FROM {t}"
|
||||||
f" WHERE {dl} IS NOT NULL GROUP BY b ORDER BY b"
|
f" WHERE {col} IS NOT NULL GROUP BY b ORDER BY b"
|
||||||
))
|
))
|
||||||
hist_map = {int(_num(r.get("b"))): _num(r.get("c")) for r in hist_rows}
|
bucket_map = {int(_num(r.get("b"))): _num(r.get("c")) for r in rows}
|
||||||
util_hist = [
|
return [{"bucket": f"{i*10}-{i*10+10}%", "value": bucket_map.get(i, 0)} for i in range(10)]
|
||||||
{"bucket": f"{i*10}-{i*10+10}%", "value": hist_map.get(i, 0)} for i in range(10)
|
|
||||||
]
|
|
||||||
|
|
||||||
top_rows = _rows(db, (
|
top_rows = _rows(db, (
|
||||||
f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') `system`,"
|
f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') `system`,"
|
||||||
@@ -180,11 +181,11 @@ def dashboard_overview(rat: str = Query("4g")):
|
|||||||
"labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]},
|
"labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]},
|
||||||
"summary": summary_out,
|
"summary": summary_out,
|
||||||
"problem_pie": problem_pie,
|
"problem_pie": problem_pie,
|
||||||
"by_system": group_by("制式"),
|
"by_system": group_by("制式", skip_unknown=True),
|
||||||
"by_band": group_by("带宽"),
|
|
||||||
"by_station": group_by("站型"),
|
"by_station": group_by("站型"),
|
||||||
"by_freq": group_by("频段", limit=12),
|
"by_freq": group_by("频段", limit=12),
|
||||||
"util_hist": util_hist,
|
"ul_hist": util_hist(ul),
|
||||||
|
"dl_hist": util_hist(dl),
|
||||||
"top_cells": top_cells,
|
"top_cells": top_cells,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -924,3 +924,13 @@ CellData 处理日志细化(`app/services/cell_data.py`):
|
|||||||
- 整页不滚动:`.cap-dashboard` 由 `overflow:auto` 改为 `height:100%; overflow:hidden; display:flex; flex-direction:column`(父链 `.content` 高 `calc(100vh-64px)` 为确定值)。布局分三段:顶栏(auto) → `.cap-spin`(flex:0 0 auto,包裹卡片+图表) → `.cap-list`(flex:1,占满剩余)。**清单已移出 `n-spin`** 成为 `.cap-body` 直接 flex 子项以获得确定高度;表格 `n-data-table` 加 `flex-height + height:100%`,由 `.cap-table-wrap`(flex:1 min-height:0) 提供高度,表体内部纵向滚动而非整页滚动。图表改为 2 行 3 列等宽网格,高度 `clamp(136px,16vh,184px)` 随视口自适应;卡片/字号整体缩小。窄屏(<1100px) 回退为 `overflow:auto` 多列换行。
|
- 整页不滚动:`.cap-dashboard` 由 `overflow:auto` 改为 `height:100%; overflow:hidden; display:flex; flex-direction:column`(父链 `.content` 高 `calc(100vh-64px)` 为确定值)。布局分三段:顶栏(auto) → `.cap-spin`(flex:0 0 auto,包裹卡片+图表) → `.cap-list`(flex:1,占满剩余)。**清单已移出 `n-spin`** 成为 `.cap-body` 直接 flex 子项以获得确定高度;表格 `n-data-table` 加 `flex-height + height:100%`,由 `.cap-table-wrap`(flex:1 min-height:0) 提供高度,表体内部纵向滚动而非整页滚动。图表改为 2 行 3 列等宽网格,高度 `clamp(136px,16vh,184px)` 随视口自适应;卡片/字号整体缩小。窄屏(<1100px) 回退为 `overflow:auto` 多列换行。
|
||||||
- 清单导出:后端新增 `GET /api/dashboard/export?rat&problem&keyword`(`dashboard.py`,sync def 走线程池),WHERE 与 `cells` 完全一致(同 problem/keyword 筛选、同排序、无分页),导出全部命中行为 CSV(`utf-8-sig`,FileResponse + BackgroundTask 清理 CACHE_DIR 临时文件);列含 CGI/NCGI、小区名称、制式/带宽/站型/频段、上下行利用率%、日均流量、用户数、高负荷问题、优化建议。前端清单工具栏「查询」旁加「导出」按钮(`downloadGet`,`exporting` 态,清单为空时禁用)。
|
- 清单导出:后端新增 `GET /api/dashboard/export?rat&problem&keyword`(`dashboard.py`,sync def 走线程池),WHERE 与 `cells` 完全一致(同 problem/keyword 筛选、同排序、无分页),导出全部命中行为 CSV(`utf-8-sig`,FileResponse + BackgroundTask 清理 CACHE_DIR 临时文件);列含 CGI/NCGI、小区名称、制式/带宽/站型/频段、上下行利用率%、日均流量、用户数、高负荷问题、优化建议。前端清单工具栏「查询」旁加「导出」按钮(`downloadGet`,`exporting` 态,清单为空时禁用)。
|
||||||
- 验证:`frontend` `vue-tsc --noEmit` 通过;后端 `py_compile dashboard.py` 通过。
|
- 验证:`frontend` `vue-tsc --noEmit` 通过;后端 `py_compile dashboard.py` 通过。
|
||||||
|
|
||||||
|
## 2026-06-26:容量看板布局重构(定高不滚动)+ KPI/图表调整
|
||||||
|
|
||||||
|
- **修复整页布局塌陷**:上一版 `.cap-dashboard { height:100% }` 在 Naive `n-layout-content`(`.content`=`height:calc(100vh-64px); overflow:auto`) 结构下未解析为确定高度,导致 `flex:1` 的清单塌成 0、下方露白、加载态只剩顶部一条。改为 `.cap-dashboard { height: calc(100vh - 64px); box-sizing:border-box; overflow:hidden; flex column }`(视口定高,不依赖父级百分比,与 `.content` 等高故无页面滚动条)。窄屏(<1180px) 回退 `height:auto; overflow:auto`。
|
||||||
|
- **清单占满剩余 + 表头固定**:`.cap-body` flex 列三段=顶栏(0) / `.cap-overview`(0,n-spin 包裹卡片+图表) / `.cap-list`(flex:1)。清单作为 `.cap-body` 直接 flex 子项;`n-data-table` 用 `flex-height + height:100%`,由 `.cap-table-wrap`(flex:1 min-height:0) 给高,**表体内部纵向滚动、表头与 scroll-x 横向条不跟随滚动**。
|
||||||
|
- **KPI 卡片 7 个**(一行,宽度缩小):新增「平均上行利用率」(`summary.avg_ul`,后端 SQL 加 `AVG(ul)*100`);「总日均流量」改为 `formatFlow()` 自动换算单位 B/KB/MB/GB/TB/PB(源值 GB,≥1024 逐级进位,<1 逐级降级),4G≈780.1 TB / 5G≈2.34 PB。
|
||||||
|
- **图表两行三列**:行一=负荷问题分布(环形) / 上行利用率分布(直方) / 下行利用率分布(直方);行二=制式分布(分组柱) / 站型分布(饼) / 频段标记小区(横向柱)。**删除带宽分布**;后端 overview 删 `by_band`、`util_hist` 拆为 `ul_hist`+`dl_hist`(复用 `util_hist(col)` 函数)。
|
||||||
|
- **制式分布排除「未知」**:`group_by` 增 `skip_unknown` 参数(`WHERE 制式 IS NOT NULL AND <> ''`),制式分布只剩 TDD/FDD。
|
||||||
|
- 行间距:统一用 flex `gap:12px`,卡片/图表 panel 内边距收紧,避免叠压。图表高度 `clamp(108px,13vh,160px)` 适配矮窗口。
|
||||||
|
- 验证:`frontend` `npm run build`(vue-tsc+vite) 通过;后端 `py_compile` 通过;4G/5G 的 avg_ul/total_flow、制式排除空值、上下行直方均经 MCP 对真实数据验证。
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cap-spin">
|
<div class="cap-overview">
|
||||||
<n-spin :show="loadingOverview">
|
<n-spin :show="loadingOverview">
|
||||||
<!-- 汇总卡片 -->
|
<!-- 汇总卡片 -->
|
||||||
<section class="cap-cards">
|
<section class="cap-cards">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
:key="card.key"
|
:key="card.key"
|
||||||
class="cap-card"
|
class="cap-card"
|
||||||
:class="card.tone"
|
:class="card.tone"
|
||||||
:style="{ animationDelay: idx * 60 + 'ms' }"
|
:style="{ animationDelay: idx * 50 + 'ms' }"
|
||||||
>
|
>
|
||||||
<div class="cap-card-icon"><n-icon><component :is="card.icon" /></n-icon></div>
|
<div class="cap-card-icon"><n-icon><component :is="card.icon" /></n-icon></div>
|
||||||
<div class="cap-card-body">
|
<div class="cap-card-body">
|
||||||
@@ -65,20 +65,20 @@
|
|||||||
<e-chart :option="problemOption" :height="chartHeight" />
|
<e-chart :option="problemOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel">
|
<div class="cap-panel">
|
||||||
<div class="cap-panel-head"><span class="bar"></span>下行利用率分布({{ overview?.labels.dl }})</div>
|
<div class="cap-panel-head"><span class="bar"></span>上行利用率分布({{ overview?.labels.ul }})</div>
|
||||||
<e-chart :option="histOption" :height="chartHeight" />
|
<e-chart :option="ulHistOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel">
|
<div class="cap-panel">
|
||||||
<div class="cap-panel-head"><span class="bar"></span>站型分布</div>
|
<div class="cap-panel-head"><span class="bar"></span>下行利用率分布({{ overview?.labels.dl }})</div>
|
||||||
<e-chart :option="stationOption" :height="chartHeight" />
|
<e-chart :option="dlHistOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel">
|
<div class="cap-panel">
|
||||||
<div class="cap-panel-head"><span class="bar"></span>制式分布(总数 / 高负荷)</div>
|
<div class="cap-panel-head"><span class="bar"></span>制式分布(总数 / 高负荷)</div>
|
||||||
<e-chart :option="systemOption" :height="chartHeight" />
|
<e-chart :option="systemOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel">
|
<div class="cap-panel">
|
||||||
<div class="cap-panel-head"><span class="bar"></span>带宽分布(总数 / 高负荷)</div>
|
<div class="cap-panel-head"><span class="bar"></span>站型分布</div>
|
||||||
<e-chart :option="bandOption" :height="chartHeight" />
|
<e-chart :option="stationOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel">
|
<div class="cap-panel">
|
||||||
<div class="cap-panel-head"><span class="bar"></span>频段标记小区 Top</div>
|
<div class="cap-panel-head"><span class="bar"></span>频段标记小区 Top</div>
|
||||||
@@ -212,10 +212,11 @@ import { NTag, darkTheme, useMessage } from 'naive-ui';
|
|||||||
import type { DataTableColumns } from 'naive-ui';
|
import type { DataTableColumns } from 'naive-ui';
|
||||||
import type { EChartsOption } from 'echarts';
|
import type { EChartsOption } from 'echarts';
|
||||||
import {
|
import {
|
||||||
AlertCircleOutline,
|
ArrowDownOutline,
|
||||||
AnalyticsOutline,
|
ArrowUpOutline,
|
||||||
BulbOutline,
|
BulbOutline,
|
||||||
CellularOutline,
|
CellularOutline,
|
||||||
|
CloudUploadOutline,
|
||||||
DownloadOutline,
|
DownloadOutline,
|
||||||
FlashOutline,
|
FlashOutline,
|
||||||
PulseOutline,
|
PulseOutline,
|
||||||
@@ -235,13 +236,13 @@ interface TopCell { id: string; name: string; system: string; band: string; stat
|
|||||||
interface Overview {
|
interface Overview {
|
||||||
rat: string;
|
rat: string;
|
||||||
labels: { ul: string; dl: string };
|
labels: { ul: string; dl: string };
|
||||||
summary: { total: number; high_load: number; util_warn: number; flow_warn: number; normal: number; avg_dl: number; max_dl: number; avg_flow: number; total_flow: number };
|
summary: { total: number; high_load: number; util_warn: number; flow_warn: number; normal: number; avg_ul: number; avg_dl: number; max_dl: number; avg_flow: number; total_flow: number };
|
||||||
problem_pie: NameVal[];
|
problem_pie: NameVal[];
|
||||||
by_system: CatStat[];
|
by_system: CatStat[];
|
||||||
by_band: CatStat[];
|
|
||||||
by_station: CatStat[];
|
by_station: CatStat[];
|
||||||
by_freq: CatStat[];
|
by_freq: CatStat[];
|
||||||
util_hist: HistBucket[];
|
ul_hist: HistBucket[];
|
||||||
|
dl_hist: HistBucket[];
|
||||||
top_cells: TopCell[];
|
top_cells: TopCell[];
|
||||||
}
|
}
|
||||||
interface CellRow { id: string; name: string; system: string; band: string; station: string; freq: string; ul: number; dl: number; flow: number; users: number; problem: string; is_high: string }
|
interface CellRow { id: string; name: string; system: string; band: string; station: string; freq: string; ul: number; dl: number; flow: number; users: number; problem: string; is_high: string }
|
||||||
@@ -264,8 +265,8 @@ const naiveDark = {
|
|||||||
borderRadius: '8px'
|
borderRadius: '8px'
|
||||||
},
|
},
|
||||||
DataTable: {
|
DataTable: {
|
||||||
thColor: 'rgba(30, 41, 59, 0.55)',
|
thColor: 'rgba(30, 41, 59, 0.85)',
|
||||||
thColorHover: 'rgba(30, 41, 59, 0.85)',
|
thColorHover: 'rgba(30, 41, 59, 0.95)',
|
||||||
thTextColor: '#cbd5e1',
|
thTextColor: '#cbd5e1',
|
||||||
thFontWeight: '600',
|
thFontWeight: '600',
|
||||||
tdColor: 'transparent',
|
tdColor: 'transparent',
|
||||||
@@ -299,7 +300,7 @@ const naiveDark = {
|
|||||||
Empty: { textColor: '#64748b', iconColor: '#475569' }
|
Empty: { textColor: '#64748b', iconColor: '#475569' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartHeight = 'clamp(136px, 16vh, 184px)';
|
const chartHeight = 'clamp(108px, 13vh, 160px)';
|
||||||
|
|
||||||
const loadingStatus = ref(true);
|
const loadingStatus = ref(true);
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
@@ -343,23 +344,25 @@ const PROBLEM_COLOR: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------- 汇总卡片(含数字滚动) ----------
|
// ---------- 汇总卡片(含数字滚动) ----------
|
||||||
const animated = reactive<Record<string, number>>({ total: 0, high_load: 0, util_warn: 0, flow_warn: 0, avg_dl: 0, total_flow: 0 });
|
const animated = reactive<Record<string, number>>({ total: 0, high_load: 0, util_warn: 0, flow_warn: 0, avg_ul: 0, avg_dl: 0, total_flow: 0 });
|
||||||
|
|
||||||
const cards = computed(() => {
|
const cards = computed(() => {
|
||||||
const s = overview.value?.summary;
|
const defs: { key: keyof typeof animated; label: string; icon: Component; tone: string; unit?: string; digits?: number; flow?: boolean }[] = [
|
||||||
const defs: { key: keyof typeof animated; label: string; icon: Component; tone: string; unit?: string; digits?: number }[] = [
|
|
||||||
{ key: 'total', label: '小区总数', icon: CellularOutline, tone: 'tone-cyan' },
|
{ key: 'total', label: '小区总数', icon: CellularOutline, tone: 'tone-cyan' },
|
||||||
{ key: 'high_load', label: '高负荷小区', icon: FlashOutline, tone: 'tone-rose' },
|
{ key: 'high_load', label: '高负荷小区', icon: FlashOutline, tone: 'tone-rose' },
|
||||||
{ key: 'util_warn', label: '利用率预警', icon: PulseOutline, tone: 'tone-blue' },
|
{ key: 'util_warn', label: '利用率预警', icon: PulseOutline, tone: 'tone-blue' },
|
||||||
{ key: 'flow_warn', label: '高流量预警', icon: TrendingUpOutline, tone: 'tone-amber' },
|
{ key: 'flow_warn', label: '高流量预警', icon: TrendingUpOutline, tone: 'tone-amber' },
|
||||||
{ key: 'avg_dl', label: '平均下行利用率', icon: AnalyticsOutline, tone: 'tone-violet', unit: '%', digits: 1 },
|
{ key: 'avg_ul', label: '平均上行利用率', icon: ArrowUpOutline, tone: 'tone-violet', unit: '%', digits: 1 },
|
||||||
{ key: 'total_flow', label: '总日均流量', icon: AlertCircleOutline, tone: 'tone-emerald', unit: 'GB', digits: 0 }
|
{ key: 'avg_dl', label: '平均下行利用率', icon: ArrowDownOutline, tone: 'tone-cyan', unit: '%', digits: 1 },
|
||||||
|
{ key: 'total_flow', label: '总日均流量', icon: CloudUploadOutline, tone: 'tone-emerald', flow: true }
|
||||||
];
|
];
|
||||||
void s;
|
return defs.map(d => {
|
||||||
return defs.map(d => ({
|
if (d.flow) {
|
||||||
...d,
|
const f = formatFlow(animated[d.key]);
|
||||||
display: formatNum(animated[d.key], d.digits ?? 0)
|
return { ...d, display: f.value, unit: f.unit };
|
||||||
}));
|
}
|
||||||
|
return { ...d, display: formatNum(animated[d.key], d.digits ?? 0) };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatNum(value: number, digits: number): string {
|
function formatNum(value: number, digits: number): string {
|
||||||
@@ -369,10 +372,29 @@ function formatNum(value: number, digits: number): string {
|
|||||||
return dec ? `${withSep}.${dec}` : withSep;
|
return dec ? `${withSep}.${dec}` : withSep;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 入参单位为 GB,自动换算到合适单位(B/KB/MB/GB/TB/PB)
|
||||||
|
function formatFlow(gb: number): { value: string; unit: string } {
|
||||||
|
const v = Number(gb) || 0;
|
||||||
|
if (v >= 1) {
|
||||||
|
const units = ['GB', 'TB', 'PB', 'EB'];
|
||||||
|
let i = 0;
|
||||||
|
let n = v;
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i += 1; }
|
||||||
|
const digits = n >= 100 ? 0 : n >= 10 ? 1 : 2;
|
||||||
|
return { value: formatNum(n, digits), unit: units[i] };
|
||||||
|
}
|
||||||
|
const units = ['GB', 'MB', 'KB', 'B'];
|
||||||
|
let i = 0;
|
||||||
|
let n = v;
|
||||||
|
while (n < 1 && i < units.length - 1) { n *= 1024; i += 1; }
|
||||||
|
const digits = n >= 100 ? 0 : 1;
|
||||||
|
return { value: formatNum(n, digits), unit: units[i] };
|
||||||
|
}
|
||||||
|
|
||||||
function animateCards(s: Overview['summary']) {
|
function animateCards(s: Overview['summary']) {
|
||||||
const targets: Record<string, number> = {
|
const targets: Record<string, number> = {
|
||||||
total: s.total, high_load: s.high_load, util_warn: s.util_warn,
|
total: s.total, high_load: s.high_load, util_warn: s.util_warn,
|
||||||
flow_warn: s.flow_warn, avg_dl: s.avg_dl, total_flow: s.total_flow
|
flow_warn: s.flow_warn, avg_ul: s.avg_ul, avg_dl: s.avg_dl, total_flow: s.total_flow
|
||||||
};
|
};
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const from = { ...animated };
|
const from = { ...animated };
|
||||||
@@ -390,7 +412,7 @@ function animateCards(s: Overview['summary']) {
|
|||||||
|
|
||||||
// ---------- echarts 通用主题 ----------
|
// ---------- echarts 通用主题 ----------
|
||||||
function baseGrid(): EChartsOption['grid'] {
|
function baseGrid(): EChartsOption['grid'] {
|
||||||
return { left: 8, right: 16, top: 28, bottom: 8, containLabel: true };
|
return { left: 8, right: 16, top: 24, bottom: 6, containLabel: true };
|
||||||
}
|
}
|
||||||
function axisLine() {
|
function axisLine() {
|
||||||
return { lineStyle: { color: 'rgba(148,163,184,0.25)' } };
|
return { lineStyle: { color: 'rgba(148,163,184,0.25)' } };
|
||||||
@@ -414,9 +436,9 @@ const problemOption = computed<EChartsOption>(() => {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
legend: { bottom: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, icon: 'circle' },
|
legend: { bottom: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, icon: 'circle', itemWidth: 9, itemHeight: 9 },
|
||||||
series: [{
|
series: [{
|
||||||
type: 'pie', radius: ['46%', '72%'], center: ['50%', '44%'], avoidLabelOverlap: true,
|
type: 'pie', radius: ['46%', '72%'], center: ['50%', '42%'], avoidLabelOverlap: true,
|
||||||
itemStyle: { borderColor: 'rgba(2,6,23,0.6)', borderWidth: 2 },
|
itemStyle: { borderColor: 'rgba(2,6,23,0.6)', borderWidth: 2 },
|
||||||
label: { show: false }, labelLine: { show: false },
|
label: { show: false }, labelLine: { show: false },
|
||||||
data
|
data
|
||||||
@@ -424,8 +446,7 @@ const problemOption = computed<EChartsOption>(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const histOption = computed<EChartsOption>(() => {
|
function makeHist(buckets: HistBucket[], c0: string, c1: string): EChartsOption {
|
||||||
const buckets = overview.value?.util_hist || [];
|
|
||||||
return {
|
return {
|
||||||
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
grid: baseGrid(),
|
grid: baseGrid(),
|
||||||
@@ -435,13 +456,16 @@ const histOption = computed<EChartsOption>(() => {
|
|||||||
type: 'bar', barWidth: '58%', data: buckets.map(b => b.value),
|
type: 'bar', barWidth: '58%', data: buckets.map(b => b.value),
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderRadius: [4, 4, 0, 0],
|
borderRadius: [4, 4, 0, 0],
|
||||||
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#22d3ee' }, { offset: 1, color: 'rgba(56,189,248,0.15)' }] }
|
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: c0 }, { offset: 1, color: c1 }] }
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
const ulHistOption = computed<EChartsOption>(() => makeHist(overview.value?.ul_hist || [], '#818cf8', 'rgba(129,140,248,0.15)'));
|
||||||
|
const dlHistOption = computed<EChartsOption>(() => makeHist(overview.value?.dl_hist || [], '#22d3ee', 'rgba(56,189,248,0.15)'));
|
||||||
|
|
||||||
function groupedBar(stats: CatStat[]): EChartsOption {
|
const systemOption = computed<EChartsOption>(() => {
|
||||||
|
const stats = overview.value?.by_system || [];
|
||||||
return {
|
return {
|
||||||
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
legend: { top: 0, right: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
|
legend: { top: 0, right: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
|
||||||
@@ -449,21 +473,19 @@ function groupedBar(stats: CatStat[]): EChartsOption {
|
|||||||
xAxis: { type: 'category', data: stats.map(s => s.name), axisLine: axisLine(), axisLabel: { ...axisLabel(), interval: 0, rotate: stats.length > 5 ? 24 : 0 }, axisTick: { show: false } },
|
xAxis: { type: 'category', data: stats.map(s => s.name), axisLine: axisLine(), axisLabel: { ...axisLabel(), interval: 0, rotate: stats.length > 5 ? 24 : 0 }, axisTick: { show: false } },
|
||||||
yAxis: { type: 'value', splitLine: { lineStyle: { color: 'rgba(148,163,184,0.12)' } }, axisLabel: axisLabel() },
|
yAxis: { type: 'value', splitLine: { lineStyle: { color: 'rgba(148,163,184,0.12)' } }, axisLabel: axisLabel() },
|
||||||
series: [
|
series: [
|
||||||
{ name: '总数', type: 'bar', barGap: '-100%', barWidth: '52%', itemStyle: { borderRadius: [3, 3, 0, 0], color: 'rgba(56,189,248,0.18)' }, data: stats.map(s => s.total) },
|
{ name: '总数', type: 'bar', barGap: '-100%', barWidth: '46%', itemStyle: { borderRadius: [3, 3, 0, 0], color: 'rgba(56,189,248,0.18)' }, data: stats.map(s => s.total) },
|
||||||
{ name: '高负荷', type: 'bar', barWidth: '52%', itemStyle: { borderRadius: [3, 3, 0, 0], color: '#fb7185' }, data: stats.map(s => s.high) }
|
{ name: '高负荷', type: 'bar', barWidth: '46%', itemStyle: { borderRadius: [3, 3, 0, 0], color: '#fb7185' }, data: stats.map(s => s.high) }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
const systemOption = computed<EChartsOption>(() => groupedBar(overview.value?.by_system || []));
|
|
||||||
const bandOption = computed<EChartsOption>(() => groupedBar(overview.value?.by_band || []));
|
|
||||||
|
|
||||||
const stationOption = computed<EChartsOption>(() => {
|
const stationOption = computed<EChartsOption>(() => {
|
||||||
const data = (overview.value?.by_station || []).map((s, i) => ({ name: s.name, value: s.total, itemStyle: { color: PALETTE[i % PALETTE.length] } }));
|
const data = (overview.value?.by_station || []).map((s, i) => ({ name: s.name, value: s.total, itemStyle: { color: PALETTE[i % PALETTE.length] } }));
|
||||||
return {
|
return {
|
||||||
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
legend: { bottom: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, icon: 'circle' },
|
legend: { bottom: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, icon: 'circle', itemWidth: 9, itemHeight: 9 },
|
||||||
series: [{
|
series: [{
|
||||||
type: 'pie', radius: ['42%', '70%'], center: ['50%', '44%'],
|
type: 'pie', radius: ['42%', '70%'], center: ['50%', '42%'],
|
||||||
itemStyle: { borderColor: 'rgba(2,6,23,0.6)', borderWidth: 2 },
|
itemStyle: { borderColor: 'rgba(2,6,23,0.6)', borderWidth: 2 },
|
||||||
label: { show: false }, labelLine: { show: false }, data
|
label: { show: false }, labelLine: { show: false }, data
|
||||||
}]
|
}]
|
||||||
@@ -474,7 +496,7 @@ const freqOption = computed<EChartsOption>(() => {
|
|||||||
const stats = [...(overview.value?.by_freq || [])].sort((a, b) => a.flagged - b.flagged);
|
const stats = [...(overview.value?.by_freq || [])].sort((a, b) => a.flagged - b.flagged);
|
||||||
return {
|
return {
|
||||||
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
grid: { left: 8, right: 24, top: 12, bottom: 8, containLabel: true },
|
grid: { left: 8, right: 24, top: 10, bottom: 6, containLabel: true },
|
||||||
xAxis: { type: 'value', splitLine: { lineStyle: { color: 'rgba(148,163,184,0.12)' } }, axisLabel: axisLabel() },
|
xAxis: { type: 'value', splitLine: { lineStyle: { color: 'rgba(148,163,184,0.12)' } }, axisLabel: axisLabel() },
|
||||||
yAxis: { type: 'category', data: stats.map(s => s.name), axisLine: axisLine(), axisLabel: axisLabel(), axisTick: { show: false } },
|
yAxis: { type: 'category', data: stats.map(s => s.name), axisLine: axisLine(), axisLabel: axisLabel(), axisTick: { show: false } },
|
||||||
series: [{
|
series: [{
|
||||||
@@ -651,11 +673,11 @@ onMounted(loadStatus);
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.cap-dashboard {
|
.cap-dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: calc(100vh - 64px);
|
||||||
min-height: 0;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
background:
|
background:
|
||||||
@@ -759,24 +781,23 @@ onMounted(loadStatus);
|
|||||||
.cap-refresh:hover:not(:disabled) { color: #22d3ee; border-color: rgba(56, 189, 248, 0.5); box-shadow: 0 0 16px rgba(56, 189, 248, 0.3); }
|
.cap-refresh:hover:not(:disabled) { color: #22d3ee; border-color: rgba(56, 189, 248, 0.5); box-shadow: 0 0 16px rgba(56, 189, 248, 0.3); }
|
||||||
.cap-refresh:disabled { opacity: 0.5; cursor: wait; }
|
.cap-refresh:disabled { opacity: 0.5; cursor: wait; }
|
||||||
|
|
||||||
/* n-spin 包裹卡片与图表(固定高度区域) */
|
/* 概览区(卡片 + 图表),固定高度,由 n-spin 包裹 */
|
||||||
.cap-spin { flex: 0 0 auto; }
|
.cap-overview { flex: 0 0 auto; }
|
||||||
.cap-spin :deep(.n-spin-content) { display: flex; flex-direction: column; gap: 12px; }
|
.cap-overview :deep(.n-spin-content) { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
/* 汇总卡片 */
|
/* 汇总卡片 */
|
||||||
.cap-cards {
|
.cap-cards {
|
||||||
flex: 0 0 auto;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.cap-card {
|
.cap-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 10px;
|
||||||
padding: 11px 13px;
|
padding: 10px 12px;
|
||||||
border-radius: 13px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(160deg, rgba(30, 41, 59, 0.55), rgba(15, 23, 42, 0.35));
|
background: linear-gradient(160deg, rgba(30, 41, 59, 0.55), rgba(15, 23, 42, 0.35));
|
||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -786,14 +807,14 @@ onMounted(loadStatus);
|
|||||||
@keyframes cardIn { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes cardIn { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
.cap-card-icon {
|
.cap-card-icon {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 38px; height: 38px; flex: 0 0 auto;
|
width: 34px; height: 34px; flex: 0 0 auto;
|
||||||
border-radius: 10px; font-size: 20px;
|
border-radius: 9px; font-size: 18px;
|
||||||
background: rgba(148, 163, 184, 0.1);
|
background: rgba(148, 163, 184, 0.1);
|
||||||
}
|
}
|
||||||
.cap-card-body { min-width: 0; }
|
.cap-card-body { min-width: 0; }
|
||||||
.cap-card-value { font-size: 22px; font-weight: 800; line-height: 1.15; color: #f8fafc; font-variant-numeric: tabular-nums; }
|
.cap-card-value { font-size: 19px; font-weight: 800; line-height: 1.15; color: #f8fafc; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
.cap-card-unit { font-size: 12px; font-weight: 600; margin-left: 3px; color: #94a3b8; }
|
.cap-card-unit { font-size: 11px; font-weight: 600; margin-left: 2px; color: #94a3b8; }
|
||||||
.cap-card-label { margin-top: 2px; font-size: 12px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.cap-card-label { margin-top: 2px; font-size: 11px; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.cap-card-glow { position: absolute; right: -30px; top: -30px; width: 90px; height: 90px; border-radius: 50%; opacity: 0.5; filter: blur(8px); }
|
.cap-card-glow { position: absolute; right: -30px; top: -30px; width: 90px; height: 90px; border-radius: 50%; opacity: 0.5; filter: blur(8px); }
|
||||||
.tone-cyan .cap-card-icon { color: #22d3ee; } .tone-cyan .cap-card-glow { background: rgba(34, 211, 238, 0.35); }
|
.tone-cyan .cap-card-icon { color: #22d3ee; } .tone-cyan .cap-card-glow { background: rgba(34, 211, 238, 0.35); }
|
||||||
.tone-rose .cap-card-icon { color: #fb7185; } .tone-rose .cap-card-glow { background: rgba(251, 113, 133, 0.35); }
|
.tone-rose .cap-card-icon { color: #fb7185; } .tone-rose .cap-card-glow { background: rgba(251, 113, 133, 0.35); }
|
||||||
@@ -807,26 +828,25 @@ onMounted(loadStatus);
|
|||||||
|
|
||||||
/* 图表区:两行三列 */
|
/* 图表区:两行三列 */
|
||||||
.cap-charts {
|
.cap-charts {
|
||||||
flex: 0 0 auto;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.cap-panel {
|
.cap-panel {
|
||||||
padding: 12px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 13px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(160deg, rgba(30, 41, 59, 0.5), rgba(15, 23, 42, 0.32));
|
background: linear-gradient(160deg, rgba(30, 41, 59, 0.5), rgba(15, 23, 42, 0.32));
|
||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
.cap-panel-head {
|
.cap-panel-head {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
margin-bottom: 6px; font-size: 13px; font-weight: 600; color: #cbd5e1;
|
margin-bottom: 4px; font-size: 13px; font-weight: 600; color: #cbd5e1;
|
||||||
}
|
}
|
||||||
.cap-panel-head.sm { font-size: 12px; margin: 14px 0 8px; }
|
.cap-panel-head.sm { font-size: 12px; margin: 14px 0 8px; }
|
||||||
.cap-panel-head .bar { width: 4px; height: 13px; border-radius: 2px; background: linear-gradient(180deg, #22d3ee, #818cf8); }
|
.cap-panel-head .bar { width: 4px; height: 13px; border-radius: 2px; background: linear-gradient(180deg, #22d3ee, #818cf8); }
|
||||||
|
|
||||||
/* 清单:占据剩余高度,表格内部滚动 */
|
/* 清单:占据剩余高度,表体内部滚动(表头固定) */
|
||||||
.cap-list { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; padding-bottom: 10px; }
|
.cap-list { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; padding-bottom: 10px; }
|
||||||
.cap-list-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
|
.cap-list-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
|
||||||
.cap-list-head .cap-panel-head { margin-bottom: 0; }
|
.cap-list-head .cap-panel-head { margin-bottom: 0; }
|
||||||
@@ -865,13 +885,14 @@ onMounted(loadStatus);
|
|||||||
.cap-sib-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 12px; color: #94a3b8; }
|
.cap-sib-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 12px; color: #94a3b8; }
|
||||||
|
|
||||||
@media (max-width: 1366px) {
|
@media (max-width: 1366px) {
|
||||||
.cap-card-value { font-size: 20px; }
|
.cap-card-value { font-size: 17px; }
|
||||||
.cap-card-icon { width: 34px; height: 34px; font-size: 18px; }
|
.cap-card-icon { width: 30px; height: 30px; font-size: 16px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1180px) {
|
||||||
.cap-dashboard { overflow: auto; }
|
.cap-dashboard { height: auto; min-height: calc(100vh - 64px); overflow: auto; }
|
||||||
.cap-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.cap-cards { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.cap-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.cap-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.cap-list { min-height: 360px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.cap-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.cap-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user