feat: 新增容量看板(4G/5G 高负荷分析大屏)
- 后端 dashboard 路由:status/overview/cells/cell,基于 4G/5G 结果表聚合分析 - 前端 CapacityDashboard:4G/5G 切换、汇总卡片(数字滚动)、echarts 图表(环形/直方/柱/饼)、问题小区清单、详情抽屉(指标+优化建议+同扇区邻区) - 侧边菜单「容量看板」置于数据处理下方;无结果表时居中提示「请先进行数据处理」 - 新增依赖 echarts;封装 EChart 组件
This commit is contained in:
@@ -0,0 +1,281 @@
|
|||||||
|
"""容量看板分析接口。
|
||||||
|
|
||||||
|
基于 4G/5G 结果表(含富集 + 高负荷判定 + 优化建议列)做聚合分析,供前端容量看板展示。
|
||||||
|
读经 `make_warehouse`(直连 MySQL 或 Metrix 平台),分析对象为“主仓库”里的结果表。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from app import state
|
||||||
|
from app.warehouse import make_warehouse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["dashboard"])
|
||||||
|
|
||||||
|
# 每个制式(结果表)的关键列映射
|
||||||
|
RAT = {
|
||||||
|
"4g": {
|
||||||
|
"table": "4G_结果表",
|
||||||
|
"id": "CGI",
|
||||||
|
"name": "小区名称",
|
||||||
|
"ul": "上行PUSCH利用率",
|
||||||
|
"dl": "下行PDSCH利用率",
|
||||||
|
"ul_label": "上行PUSCH利用率",
|
||||||
|
"dl_label": "下行PDSCH利用率",
|
||||||
|
"users": "YY-RRC连接建立最大用户数",
|
||||||
|
},
|
||||||
|
"5g": {
|
||||||
|
"table": "5G_结果表",
|
||||||
|
"id": "NCGI",
|
||||||
|
"name": "CU小区配置名称",
|
||||||
|
"ul": "上行PRB平均利用率",
|
||||||
|
"dl": "下行PRB平均利用率",
|
||||||
|
"ul_label": "上行PRB利用率",
|
||||||
|
"dl_label": "下行PRB利用率",
|
||||||
|
"users": "RRC连接平均连接用户数",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
FLOW = "日均流量(GB)"
|
||||||
|
PROBLEMS = ["高负荷", "利用率预警", "高流量预警"]
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
return make_warehouse(state.current_config())
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(value: str) -> str:
|
||||||
|
return str(value).replace("\\", "\\\\").replace("'", "''")
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(db, sql: str) -> list[dict[str, Any]]:
|
||||||
|
ok, result = db.execute_sql(sql)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=500, detail=str(result))
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _num(value: Any, digits: int | None = None) -> float | int:
|
||||||
|
try:
|
||||||
|
num = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
if digits is None:
|
||||||
|
return int(num)
|
||||||
|
return round(num, digits)
|
||||||
|
|
||||||
|
|
||||||
|
def _rat(rat: str) -> dict[str, str]:
|
||||||
|
cfg = RAT.get((rat or "").lower())
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=400, detail="rat 仅支持 4g / 5g")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_tables(db) -> set[str]:
|
||||||
|
try:
|
||||||
|
return {str(t).lower() for t in db.get_tables()}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(status_code=500, detail=f"读取数据表失败: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard/status")
|
||||||
|
def dashboard_status():
|
||||||
|
tables = _existing_tables(_db())
|
||||||
|
has_4g = RAT["4g"]["table"].lower() in tables
|
||||||
|
has_5g = RAT["5g"]["table"].lower() in tables
|
||||||
|
return {"has_4g": has_4g, "has_5g": has_5g, "ready": has_4g and has_5g}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard/overview")
|
||||||
|
def dashboard_overview(rat: str = Query("4g")):
|
||||||
|
cfg = _rat(rat)
|
||||||
|
db = _db()
|
||||||
|
if cfg["table"].lower() not in _existing_tables(db):
|
||||||
|
raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理")
|
||||||
|
|
||||||
|
t = f"`{cfg['table']}`"
|
||||||
|
ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`"
|
||||||
|
|
||||||
|
summary = _rows(db, (
|
||||||
|
f"SELECT COUNT(*) total,"
|
||||||
|
f" SUM(`高负荷问题`='高负荷') high_load,"
|
||||||
|
f" SUM(`高负荷问题`='利用率预警') util_warn,"
|
||||||
|
f" SUM(`高负荷问题`='高流量预警') flow_warn,"
|
||||||
|
f" SUM(`高负荷问题` IS NULL) normal,"
|
||||||
|
f" ROUND(AVG({dl})*100,1) avg_dl,"
|
||||||
|
f" ROUND(MAX({dl})*100,1) max_dl,"
|
||||||
|
f" ROUND(AVG({flow}),2) avg_flow,"
|
||||||
|
f" ROUND(SUM({flow}),1) total_flow"
|
||||||
|
f" FROM {t}"
|
||||||
|
))
|
||||||
|
s = summary[0] if summary else {}
|
||||||
|
summary_out = {
|
||||||
|
"total": _num(s.get("total")),
|
||||||
|
"high_load": _num(s.get("high_load")),
|
||||||
|
"util_warn": _num(s.get("util_warn")),
|
||||||
|
"flow_warn": _num(s.get("flow_warn")),
|
||||||
|
"normal": _num(s.get("normal")),
|
||||||
|
"avg_dl": _num(s.get("avg_dl"), 1),
|
||||||
|
"max_dl": _num(s.get("max_dl"), 1),
|
||||||
|
"avg_flow": _num(s.get("avg_flow"), 2),
|
||||||
|
"total_flow": _num(s.get("total_flow"), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
problem_pie = [
|
||||||
|
{"name": "高负荷", "value": summary_out["high_load"]},
|
||||||
|
{"name": "利用率预警", "value": summary_out["util_warn"]},
|
||||||
|
{"name": "高流量预警", "value": summary_out["flow_warn"]},
|
||||||
|
{"name": "正常", "value": summary_out["normal"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
def group_by(col: str, limit: int = 0) -> list[dict[str, Any]]:
|
||||||
|
limit_sql = f" LIMIT {int(limit)}" if limit else ""
|
||||||
|
rows = _rows(db, (
|
||||||
|
f"SELECT IFNULL(`{col}`,'未知') name, COUNT(*) total,"
|
||||||
|
f" SUM(`是否高负荷小区`='是') high,"
|
||||||
|
f" SUM(`高负荷问题` IS NOT NULL) flagged"
|
||||||
|
f" FROM {t} GROUP BY `{col}` ORDER BY flagged DESC, total DESC{limit_sql}"
|
||||||
|
))
|
||||||
|
return [
|
||||||
|
{"name": str(r.get("name")), "total": _num(r.get("total")),
|
||||||
|
"high": _num(r.get("high")), "flagged": _num(r.get("flagged"))}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# 下行利用率分布(0-10%...90-100%)
|
||||||
|
hist_rows = _rows(db, (
|
||||||
|
f"SELECT LEAST(FLOOR({dl}*10),9) b, COUNT(*) c FROM {t}"
|
||||||
|
f" WHERE {dl} 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}
|
||||||
|
util_hist = [
|
||||||
|
{"bucket": f"{i*10}-{i*10+10}%", "value": hist_map.get(i, 0)} for i in range(10)
|
||||||
|
]
|
||||||
|
|
||||||
|
top_rows = _rows(db, (
|
||||||
|
f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') system,"
|
||||||
|
f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station,"
|
||||||
|
f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users,"
|
||||||
|
f" IFNULL(`高负荷问题`,'') problem"
|
||||||
|
f" FROM {t} WHERE `高负荷问题`='高负荷' ORDER BY {dl} DESC, {flow} DESC LIMIT 10"
|
||||||
|
))
|
||||||
|
top_cells = [
|
||||||
|
{"id": str(r.get("id")), "name": str(r.get("name")), "system": str(r.get("system")),
|
||||||
|
"band": str(r.get("band")), "station": str(r.get("station")),
|
||||||
|
"ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1),
|
||||||
|
"flow": _num(r.get("flow"), 2), "users": _num(r.get("users")), "problem": str(r.get("problem"))}
|
||||||
|
for r in top_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rat": rat.lower(),
|
||||||
|
"labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]},
|
||||||
|
"summary": summary_out,
|
||||||
|
"problem_pie": problem_pie,
|
||||||
|
"by_system": group_by("制式"),
|
||||||
|
"by_band": group_by("带宽"),
|
||||||
|
"by_station": group_by("站型"),
|
||||||
|
"by_freq": group_by("频段", limit=12),
|
||||||
|
"util_hist": util_hist,
|
||||||
|
"top_cells": top_cells,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard/cells")
|
||||||
|
def dashboard_cells(
|
||||||
|
rat: str = Query("4g"),
|
||||||
|
problem: str = Query(""),
|
||||||
|
keyword: str = Query(""),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=200),
|
||||||
|
):
|
||||||
|
cfg = _rat(rat)
|
||||||
|
db = _db()
|
||||||
|
if cfg["table"].lower() not in _existing_tables(db):
|
||||||
|
raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在,请先进行数据处理")
|
||||||
|
|
||||||
|
t = f"`{cfg['table']}`"
|
||||||
|
ul, dl, flow, users = f"`{cfg['ul']}`", f"`{cfg['dl']}`", f"`{FLOW}`", f"`{cfg['users']}`"
|
||||||
|
|
||||||
|
wheres = ["`高负荷问题` IS NOT NULL"]
|
||||||
|
if problem in PROBLEMS:
|
||||||
|
wheres = [f"`高负荷问题`='{problem}'"]
|
||||||
|
if keyword.strip():
|
||||||
|
kw = _esc(keyword.strip())
|
||||||
|
wheres.append(f"(`{cfg['id']}` LIKE '%{kw}%' OR `{cfg['name']}` LIKE '%{kw}%')")
|
||||||
|
where_sql = " WHERE " + " AND ".join(wheres)
|
||||||
|
|
||||||
|
total = _num(_rows(db, f"SELECT COUNT(*) c FROM {t}{where_sql}")[0].get("c"))
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = _rows(db, (
|
||||||
|
f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') system,"
|
||||||
|
f" IFNULL(`带宽`,'') band, IFNULL(`站型`,'') station, IFNULL(`频段`,'') freq,"
|
||||||
|
f" ROUND({ul}*100,1) ul, ROUND({dl}*100,1) dl, ROUND({flow},2) flow, ROUND({users},0) users,"
|
||||||
|
f" IFNULL(`高负荷问题`,'') problem, IFNULL(`是否高负荷小区`,'否') is_high"
|
||||||
|
f" FROM {t}{where_sql}"
|
||||||
|
f" ORDER BY FIELD(`高负荷问题`,'高负荷','高流量预警','利用率预警'), {dl} DESC"
|
||||||
|
f" LIMIT {int(page_size)} OFFSET {int(offset)}"
|
||||||
|
))
|
||||||
|
items = [
|
||||||
|
{"id": str(r.get("id")), "name": str(r.get("name")), "system": str(r.get("system")),
|
||||||
|
"band": str(r.get("band")), "station": str(r.get("station")), "freq": str(r.get("freq")),
|
||||||
|
"ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1), "flow": _num(r.get("flow"), 2),
|
||||||
|
"users": _num(r.get("users")), "problem": str(r.get("problem")), "is_high": str(r.get("is_high"))}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard/cell")
|
||||||
|
def dashboard_cell(rat: str = Query("4g"), id: str = Query(...)):
|
||||||
|
cfg = _rat(rat)
|
||||||
|
db = _db()
|
||||||
|
if cfg["table"].lower() not in _existing_tables(db):
|
||||||
|
raise HTTPException(status_code=404, detail=f"结果表 {cfg['table']} 不存在")
|
||||||
|
|
||||||
|
t = f"`{cfg['table']}`"
|
||||||
|
cell_id = _esc(id)
|
||||||
|
rows = _rows(db, f"SELECT * FROM {t} WHERE `{cfg['id']}`='{cell_id}' LIMIT 1")
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="未找到该小区")
|
||||||
|
row = rows[0]
|
||||||
|
|
||||||
|
# 同扇区同 PLMN 的兄弟小区(用于详情页的均衡上下文)
|
||||||
|
sector = str(row.get("扇区") or "")
|
||||||
|
siblings: list[dict[str, Any]] = []
|
||||||
|
if sector:
|
||||||
|
plmn = _esc("-".join(str(id).split("-")[:2]))
|
||||||
|
sector_e = _esc(sector)
|
||||||
|
dl = f"`{cfg['dl']}`"
|
||||||
|
sib_rows = _rows(db, (
|
||||||
|
f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`带宽`,'') band,"
|
||||||
|
f" IFNULL(`频段`,'') freq, ROUND(`{cfg['ul']}`*100,1) ul, ROUND({dl}*100,1) dl,"
|
||||||
|
f" ROUND(`{FLOW}`,2) flow, IFNULL(`高负荷问题`,'正常') problem"
|
||||||
|
f" FROM {t} WHERE `扇区`='{sector_e}' AND SUBSTRING_INDEX(`{cfg['id']}`,'-',2)='{plmn}'"
|
||||||
|
f" AND `{cfg['id']}`<>'{cell_id}' ORDER BY {dl} DESC LIMIT 30"
|
||||||
|
))
|
||||||
|
siblings = [
|
||||||
|
{"id": str(r.get("id")), "name": str(r.get("name")), "band": str(r.get("band")),
|
||||||
|
"freq": str(r.get("freq")), "ul": _num(r.get("ul"), 1), "dl": _num(r.get("dl"), 1),
|
||||||
|
"flow": _num(r.get("flow"), 2), "problem": str(r.get("problem"))}
|
||||||
|
for r in sib_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# 原始行转为字符串友好的 dict(数值保留,None→空)
|
||||||
|
detail = {str(k): (v if v is not None else "") for k, v in row.items()}
|
||||||
|
return {
|
||||||
|
"rat": rat.lower(),
|
||||||
|
"id": str(row.get(cfg["id"]) or id),
|
||||||
|
"name": str(row.get(cfg["name"]) or ""),
|
||||||
|
"labels": {"ul": cfg["ul_label"], "dl": cfg["dl_label"]},
|
||||||
|
"id_field": cfg["id"],
|
||||||
|
"name_field": cfg["name"],
|
||||||
|
"ul_field": cfg["ul"],
|
||||||
|
"dl_field": cfg["dl"],
|
||||||
|
"users_field": cfg["users"],
|
||||||
|
"flow_field": FLOW,
|
||||||
|
"detail": detail,
|
||||||
|
"siblings": siblings,
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ from app.api.routers import (
|
|||||||
cache,
|
cache,
|
||||||
cell_data,
|
cell_data,
|
||||||
config,
|
config,
|
||||||
|
dashboard,
|
||||||
database,
|
database,
|
||||||
health,
|
health,
|
||||||
history,
|
history,
|
||||||
@@ -112,6 +113,7 @@ def register_routes(app: FastAPI) -> None:
|
|||||||
config.router,
|
config.router,
|
||||||
cache.router,
|
cache.router,
|
||||||
cell_data.router,
|
cell_data.router,
|
||||||
|
dashboard.router,
|
||||||
script.router,
|
script.router,
|
||||||
]
|
]
|
||||||
for router in routers:
|
for router in routers:
|
||||||
|
|||||||
@@ -905,3 +905,15 @@ CellData 处理日志细化(`app/services/cell_data.py`):
|
|||||||
附带修复:`frontend/src/vite-env.d.ts` 增加 `declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js'`,修复 ScriptPanel SQL 补全的深层导入缺类型声明导致 `vue-tsc`(`npm run build`)失败(TS7016)。
|
附带修复:`frontend/src/vite-env.d.ts` 增加 `declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js'`,修复 ScriptPanel SQL 补全的深层导入缺类型声明导致 `vue-tsc`(`npm run build`)失败(TS7016)。
|
||||||
|
|
||||||
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过(DatabasePanel chunk≈124KB)。
|
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过(DatabasePanel chunk≈124KB)。
|
||||||
|
|
||||||
|
## 2026-06-26:容量看板(4G/5G 高负荷分析大屏)
|
||||||
|
|
||||||
|
- 后端 `app/api/routers/dashboard.py`(`main.py` 注册):基于「主仓库」(`make_warehouse`,直连 MySQL 或 Metrix) 的 4G/5G 结果表聚合分析,4 个接口:
|
||||||
|
- `GET /api/dashboard/status` → `{has_4g,has_5g,ready}`(两表都在才 ready);
|
||||||
|
- `GET /api/dashboard/overview?rat=4g|5g` → 汇总(总数/高负荷/利用率预警/高流量预警/正常/平均下行利用率/总流量) + 问题分布饼 + 制式/带宽/站型/频段 分组统计 + 下行利用率 10 档直方 + Top10 高负荷小区;
|
||||||
|
- `GET /api/dashboard/cells?rat&problem&keyword&page&page_size` → 问题小区清单(按 高负荷>高流量预警>利用率预警 + 下行利用率 排序,可筛选);
|
||||||
|
- `GET /api/dashboard/cell?rat&id` → 单小区全字段 + 优化建议 + 同扇区同 PLMN 兄弟小区。
|
||||||
|
- 4G 指标用 PUSCH/PDSCH 利用率与 YY-RRC,5G 用 PRB 利用率与 RRC 平均;execute_sql 原始 SQL,用户输入(id/keyword) 转义。
|
||||||
|
- 前端:新增依赖 `echarts`;`components/EChart.vue`(echarts 封装,ResizeObserver 自适应 + dispose);`components/CapacityDashboard.vue`(科技感大屏:4G/5G 分段切换、6 张汇总卡片含数字滚动、6 个图表(环形/直方/分组柱/饼/横向柱)、问题小区清单(筛选+分页+点击行打开)、右侧详情抽屉(关键指标 + 优化建议 + 同扇区兄弟小区))。无结果表时横纵居中「请先进行数据处理」。
|
||||||
|
- 路由 `/dashboard`(name=`dashboard`,懒加载);侧边菜单「容量看板」置于「数据处理」下方(`AppShell` menuKeys/menuOptions + `StatsChartOutline`)。看板自带深色科技风(径向辉光/玻璃拟态/霓虹色),不随应用明暗主题;图表 echarts 自定义深色配色。
|
||||||
|
- 验证:`npm run build`(vue-tsc)通过(CapacityDashboard 懒加载 chunk≈1.16MB 含 echarts);后端 `compileall` 通过;各接口 SQL 经 MCP 对真实数据验证(4G 高负荷 792 / 5G 46,同扇区邻区 6–8 个)。
|
||||||
|
|||||||
Generated
+26
-17
@@ -10,9 +10,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"swagger-ui-dist": "^5.32.6",
|
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^5.0.7"
|
"vue-router": "^5.0.7"
|
||||||
},
|
},
|
||||||
@@ -1008,13 +1008,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@scarf/scarf": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
@@ -1419,6 +1412,16 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -1937,15 +1940,6 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swagger-ui-dist": {
|
|
||||||
"version": "5.32.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz",
|
|
||||||
"integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@scarf/scarf": "=1.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -1968,6 +1962,12 @@
|
|||||||
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
|
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -2379,6 +2379,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"vue": "^3.5.0",
|
"vue": "^3.5.0",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ import {
|
|||||||
MoonOutline,
|
MoonOutline,
|
||||||
ServerOutline,
|
ServerOutline,
|
||||||
SettingsOutline,
|
SettingsOutline,
|
||||||
|
StatsChartOutline,
|
||||||
SunnyOutline
|
SunnyOutline
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
|
|
||||||
@@ -147,12 +148,13 @@ const loginLoading = ref(false);
|
|||||||
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
|
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
|
||||||
const licenseModalVisible = ref(false);
|
const licenseModalVisible = ref(false);
|
||||||
const brandClickCount = ref(0);
|
const brandClickCount = ref(0);
|
||||||
const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const;
|
const menuKeys = ['workflow', 'dashboard', 'history', 'database', 'script', 'settings'] as const;
|
||||||
type MenuKey = (typeof menuKeys)[number];
|
type MenuKey = (typeof menuKeys)[number];
|
||||||
let brandClickResetTimer: number | undefined;
|
let brandClickResetTimer: number | undefined;
|
||||||
|
|
||||||
const menuOptions: MenuOption[] = [
|
const menuOptions: MenuOption[] = [
|
||||||
{ label: '数据处理', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
|
{ label: '数据处理', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
|
||||||
|
{ label: '容量看板', key: 'dashboard', icon: renderIcon(StatsChartOutline) },
|
||||||
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
||||||
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
|
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
|
||||||
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
|
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
|
||||||
|
|||||||
@@ -0,0 +1,787 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cap-dashboard">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loadingStatus" class="cap-center">
|
||||||
|
<n-spin size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无结果表:居中提示 -->
|
||||||
|
<div v-else-if="!ready" class="cap-center cap-empty">
|
||||||
|
<div class="cap-empty-orb"></div>
|
||||||
|
<p class="cap-empty-text">请先进行数据处理</p>
|
||||||
|
<p class="cap-empty-sub">检测到数据库中暂无 4G / 5G 结果表,完成「数据处理」后即可查看容量看板。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 看板主体 -->
|
||||||
|
<div v-else class="cap-body">
|
||||||
|
<header class="cap-topbar">
|
||||||
|
<div class="cap-title-wrap">
|
||||||
|
<span class="cap-spark"></span>
|
||||||
|
<h2 class="cap-title">容量负荷看板</h2>
|
||||||
|
<span class="cap-sub">高负荷小区分析 · 实时数据</span>
|
||||||
|
</div>
|
||||||
|
<div class="cap-controls">
|
||||||
|
<div class="cap-seg">
|
||||||
|
<button
|
||||||
|
v-for="opt in ratOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="cap-seg-btn"
|
||||||
|
:class="{ active: rat === opt.value }"
|
||||||
|
@click="switchRat(opt.value)"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
<button class="cap-refresh" type="button" :disabled="loadingOverview" title="刷新" @click="reload">
|
||||||
|
<n-icon><RefreshOutline /></n-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<n-spin :show="loadingOverview">
|
||||||
|
<!-- 汇总卡片 -->
|
||||||
|
<section class="cap-cards">
|
||||||
|
<div
|
||||||
|
v-for="(card, idx) in cards"
|
||||||
|
:key="card.key"
|
||||||
|
class="cap-card"
|
||||||
|
:class="card.tone"
|
||||||
|
:style="{ animationDelay: idx * 60 + 'ms' }"
|
||||||
|
>
|
||||||
|
<div class="cap-card-icon"><n-icon><component :is="card.icon" /></n-icon></div>
|
||||||
|
<div class="cap-card-body">
|
||||||
|
<div class="cap-card-value">{{ card.display }}<span v-if="card.unit" class="cap-card-unit">{{ card.unit }}</span></div>
|
||||||
|
<div class="cap-card-label">{{ card.label }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cap-card-glow"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 图表区 -->
|
||||||
|
<section class="cap-charts">
|
||||||
|
<div class="cap-panel span-2">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>负荷问题分布</div>
|
||||||
|
<e-chart :option="problemOption" height="280px" />
|
||||||
|
</div>
|
||||||
|
<div class="cap-panel span-4">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>下行利用率分布({{ overview?.labels.dl }})</div>
|
||||||
|
<e-chart :option="histOption" height="280px" />
|
||||||
|
</div>
|
||||||
|
<div class="cap-panel span-3">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>制式分布(总数 / 高负荷)</div>
|
||||||
|
<e-chart :option="systemOption" height="260px" />
|
||||||
|
</div>
|
||||||
|
<div class="cap-panel span-3">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>带宽分布(总数 / 高负荷)</div>
|
||||||
|
<e-chart :option="bandOption" height="260px" />
|
||||||
|
</div>
|
||||||
|
<div class="cap-panel span-2">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>站型分布</div>
|
||||||
|
<e-chart :option="stationOption" height="260px" />
|
||||||
|
</div>
|
||||||
|
<div class="cap-panel span-4">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>频段标记小区 Top</div>
|
||||||
|
<e-chart :option="freqOption" height="260px" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 高负荷 / 预警 清单 -->
|
||||||
|
<section class="cap-panel cap-list">
|
||||||
|
<div class="cap-list-head">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>问题小区清单</div>
|
||||||
|
<div class="cap-list-filters">
|
||||||
|
<div class="cap-seg sm">
|
||||||
|
<button
|
||||||
|
v-for="opt in problemFilters"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="cap-seg-btn"
|
||||||
|
:class="{ active: problemFilter === opt.value }"
|
||||||
|
@click="setProblemFilter(opt.value)"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="keyword"
|
||||||
|
class="cap-search"
|
||||||
|
clearable
|
||||||
|
placeholder="CGI / 小区名称"
|
||||||
|
@keyup.enter="searchCells"
|
||||||
|
@clear="searchCells"
|
||||||
|
>
|
||||||
|
<template #prefix><n-icon><SearchOutline /></n-icon></template>
|
||||||
|
</n-input>
|
||||||
|
<n-button size="small" type="primary" ghost @click="searchCells">查询</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-data-table
|
||||||
|
class="cap-table"
|
||||||
|
:columns="columns"
|
||||||
|
:data="cells"
|
||||||
|
:loading="loadingCells"
|
||||||
|
:bordered="false"
|
||||||
|
:scroll-x="1180"
|
||||||
|
:row-props="rowProps"
|
||||||
|
/>
|
||||||
|
<div class="cap-pager">
|
||||||
|
<span class="cap-pager-total">共 {{ cellTotal }} 个问题小区</span>
|
||||||
|
<n-pagination
|
||||||
|
:page="page"
|
||||||
|
:page-count="pageCount"
|
||||||
|
:page-size="pageSize"
|
||||||
|
@update:page="changePage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 小区详情抽屉 -->
|
||||||
|
<n-drawer v-model:show="detailVisible" :width="detailWidth" placement="right" class="cap-drawer">
|
||||||
|
<n-drawer-content :native-scrollbar="false" closable>
|
||||||
|
<template #header>
|
||||||
|
<div class="cap-detail-head">
|
||||||
|
<span class="cap-detail-id">{{ detail?.id }}</span>
|
||||||
|
<span v-if="detail?.name" class="cap-detail-name">{{ detail?.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="loadingDetail" class="cap-center"><n-spin /></div>
|
||||||
|
<div v-else-if="detail" class="cap-detail">
|
||||||
|
<div class="cap-detail-tags">
|
||||||
|
<span class="cap-tag" :class="problemClass(detailProblem)">{{ detailProblem || '正常' }}</span>
|
||||||
|
<span class="cap-tag plain">{{ detailValue('制式') }}</span>
|
||||||
|
<span class="cap-tag plain">{{ detailValue('带宽') }}</span>
|
||||||
|
<span class="cap-tag plain">{{ detailValue('站型') }}</span>
|
||||||
|
<span class="cap-tag plain">{{ detailValue('频段') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cap-detail-grid">
|
||||||
|
<div v-for="m in detailMetrics" :key="m.label" class="cap-metric">
|
||||||
|
<div class="cap-metric-label">{{ m.label }}</div>
|
||||||
|
<div class="cap-metric-value">{{ m.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="detailSuggestion" class="cap-suggest">
|
||||||
|
<div class="cap-suggest-head"><n-icon><BulbOutline /></n-icon> 优化建议</div>
|
||||||
|
<p class="cap-suggest-text">{{ detailSuggestion }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cap-sib">
|
||||||
|
<div class="cap-panel-head sm"><span class="bar"></span>同扇区同运营商小区({{ detail.siblings.length }})</div>
|
||||||
|
<n-empty v-if="!detail.siblings.length" size="small" description="无同扇区其他小区" />
|
||||||
|
<div v-else class="cap-sib-list">
|
||||||
|
<div v-for="sib in detail.siblings" :key="sib.id" class="cap-sib-item">
|
||||||
|
<div class="cap-sib-main">
|
||||||
|
<span class="cap-sib-id">{{ sib.id }}</span>
|
||||||
|
<span class="cap-sib-name">{{ sib.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cap-sib-meta">
|
||||||
|
<span class="cap-tag plain xs">{{ sib.band || '-' }}</span>
|
||||||
|
<span class="cap-tag plain xs">{{ sib.freq || '-' }}</span>
|
||||||
|
<span class="cap-sib-stat">下行 {{ sib.dl }}%</span>
|
||||||
|
<span class="cap-sib-stat">流量 {{ sib.flow }}GB</span>
|
||||||
|
<span class="cap-tag xs" :class="problemClass(sib.problem)">{{ sib.problem }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-drawer-content>
|
||||||
|
</n-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, reactive, ref, type Component } from 'vue';
|
||||||
|
import { NTag, useMessage } from 'naive-ui';
|
||||||
|
import type { DataTableColumns } from 'naive-ui';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import {
|
||||||
|
AlertCircleOutline,
|
||||||
|
AnalyticsOutline,
|
||||||
|
BulbOutline,
|
||||||
|
CellularOutline,
|
||||||
|
FlashOutline,
|
||||||
|
PulseOutline,
|
||||||
|
RefreshOutline,
|
||||||
|
SearchOutline,
|
||||||
|
TrendingUpOutline
|
||||||
|
} from '@vicons/ionicons5';
|
||||||
|
|
||||||
|
import { apiGet } from '../api/client';
|
||||||
|
import EChart from './EChart.vue';
|
||||||
|
|
||||||
|
type Rat = '4g' | '5g';
|
||||||
|
interface NameVal { name: string; value: number }
|
||||||
|
interface CatStat { name: string; total: number; high: number; flagged: number }
|
||||||
|
interface HistBucket { bucket: string; value: number }
|
||||||
|
interface TopCell { id: string; name: string; system: string; band: string; station: string; ul: number; dl: number; flow: number; users: number; problem: string }
|
||||||
|
interface Overview {
|
||||||
|
rat: 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 };
|
||||||
|
problem_pie: NameVal[];
|
||||||
|
by_system: CatStat[];
|
||||||
|
by_band: CatStat[];
|
||||||
|
by_station: CatStat[];
|
||||||
|
by_freq: CatStat[];
|
||||||
|
util_hist: HistBucket[];
|
||||||
|
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 Sibling { id: string; name: string; band: string; freq: string; ul: number; dl: number; flow: number; problem: string }
|
||||||
|
interface CellDetail {
|
||||||
|
rat: string; id: string; name: string; labels: { ul: string; dl: string };
|
||||||
|
ul_field: string; dl_field: string; users_field: string; flow_field: string;
|
||||||
|
detail: Record<string, unknown>; siblings: Sibling[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
|
const loadingStatus = ref(true);
|
||||||
|
const ready = ref(false);
|
||||||
|
const rat = ref<Rat>('4g');
|
||||||
|
const ratOptions: { label: string; value: Rat }[] = [
|
||||||
|
{ label: '4G', value: '4g' },
|
||||||
|
{ label: '5G', value: '5g' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadingOverview = ref(false);
|
||||||
|
const overview = ref<Overview | null>(null);
|
||||||
|
|
||||||
|
const cells = ref<CellRow[]>([]);
|
||||||
|
const cellTotal = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
const loadingCells = ref(false);
|
||||||
|
const problemFilter = ref('');
|
||||||
|
const keyword = ref('');
|
||||||
|
const problemFilters = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '高负荷', value: '高负荷' },
|
||||||
|
{ label: '高流量预警', value: '高流量预警' },
|
||||||
|
{ label: '利用率预警', value: '利用率预警' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const loadingDetail = ref(false);
|
||||||
|
const detail = ref<CellDetail | null>(null);
|
||||||
|
const detailWidth = ref(Math.min(560, typeof window !== 'undefined' ? window.innerWidth - 24 : 560));
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(cellTotal.value / pageSize)));
|
||||||
|
|
||||||
|
const PALETTE = ['#38bdf8', '#22d3ee', '#818cf8', '#c084fc', '#fbbf24', '#fb7185', '#34d399', '#f472b6'];
|
||||||
|
const PROBLEM_COLOR: Record<string, string> = {
|
||||||
|
高负荷: '#fb7185',
|
||||||
|
高流量预警: '#fbbf24',
|
||||||
|
利用率预警: '#38bdf8',
|
||||||
|
正常: '#475569'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 汇总卡片(含数字滚动) ----------
|
||||||
|
const animated = reactive<Record<string, number>>({ total: 0, high_load: 0, util_warn: 0, flow_warn: 0, avg_dl: 0, total_flow: 0 });
|
||||||
|
|
||||||
|
const cards = computed(() => {
|
||||||
|
const s = overview.value?.summary;
|
||||||
|
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: 'high_load', label: '高负荷小区', icon: FlashOutline, tone: 'tone-rose' },
|
||||||
|
{ key: 'util_warn', label: '利用率预警', icon: PulseOutline, tone: 'tone-blue' },
|
||||||
|
{ key: 'flow_warn', label: '高流量预警', icon: TrendingUpOutline, tone: 'tone-amber' },
|
||||||
|
{ key: 'avg_dl', label: '平均下行利用率', icon: AnalyticsOutline, tone: 'tone-violet', unit: '%', digits: 1 },
|
||||||
|
{ key: 'total_flow', label: '总日均流量', icon: AlertCircleOutline, tone: 'tone-emerald', unit: 'GB', digits: 0 }
|
||||||
|
];
|
||||||
|
void s;
|
||||||
|
return defs.map(d => ({
|
||||||
|
...d,
|
||||||
|
display: formatNum(animated[d.key], d.digits ?? 0)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatNum(value: number, digits: number): string {
|
||||||
|
const fixed = Number(value).toFixed(digits);
|
||||||
|
const [int, dec] = fixed.split('.');
|
||||||
|
const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
return dec ? `${withSep}.${dec}` : withSep;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateCards(s: Overview['summary']) {
|
||||||
|
const targets: Record<string, number> = {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
const start = performance.now();
|
||||||
|
const from = { ...animated };
|
||||||
|
const step = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / 700);
|
||||||
|
const ease = 1 - Math.pow(1 - t, 3);
|
||||||
|
for (const k of Object.keys(targets)) {
|
||||||
|
animated[k] = from[k] + (targets[k] - from[k]) * ease;
|
||||||
|
}
|
||||||
|
if (t < 1) requestAnimationFrame(step);
|
||||||
|
else Object.assign(animated, targets);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- echarts 通用主题 ----------
|
||||||
|
function baseGrid(): EChartsOption['grid'] {
|
||||||
|
return { left: 8, right: 16, top: 28, bottom: 8, containLabel: true };
|
||||||
|
}
|
||||||
|
function axisLine() {
|
||||||
|
return { lineStyle: { color: 'rgba(148,163,184,0.25)' } };
|
||||||
|
}
|
||||||
|
function axisLabel() {
|
||||||
|
return { color: '#94a3b8', fontSize: 11 };
|
||||||
|
}
|
||||||
|
function tooltipBase(): EChartsOption['tooltip'] {
|
||||||
|
return {
|
||||||
|
backgroundColor: 'rgba(15,23,42,0.92)',
|
||||||
|
borderColor: 'rgba(56,189,248,0.35)',
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: { color: '#e2e8f0', fontSize: 12 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const problemOption = computed<EChartsOption>(() => {
|
||||||
|
const data = (overview.value?.problem_pie || []).map(d => ({
|
||||||
|
name: d.name, value: d.value,
|
||||||
|
itemStyle: { color: PROBLEM_COLOR[d.name] || '#64748b' }
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
legend: { bottom: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, icon: 'circle' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie', radius: ['46%', '72%'], center: ['50%', '44%'], avoidLabelOverlap: true,
|
||||||
|
itemStyle: { borderColor: 'rgba(2,6,23,0.6)', borderWidth: 2 },
|
||||||
|
label: { show: false }, labelLine: { show: false },
|
||||||
|
data
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const histOption = computed<EChartsOption>(() => {
|
||||||
|
const buckets = overview.value?.util_hist || [];
|
||||||
|
return {
|
||||||
|
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: baseGrid(),
|
||||||
|
xAxis: { type: 'category', data: buckets.map(b => b.bucket), axisLine: axisLine(), axisLabel: { ...axisLabel(), rotate: 30 }, axisTick: { show: false } },
|
||||||
|
yAxis: { type: 'value', splitLine: { lineStyle: { color: 'rgba(148,163,184,0.12)' } }, axisLabel: axisLabel() },
|
||||||
|
series: [{
|
||||||
|
type: 'bar', barWidth: '58%', data: buckets.map(b => b.value),
|
||||||
|
itemStyle: {
|
||||||
|
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)' }] }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function groupedBar(stats: CatStat[]): EChartsOption {
|
||||||
|
return {
|
||||||
|
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
legend: { top: 0, right: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, itemWidth: 10, itemHeight: 10 },
|
||||||
|
grid: baseGrid(),
|
||||||
|
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() },
|
||||||
|
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', barWidth: '52%', 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 data = (overview.value?.by_station || []).map((s, i) => ({ name: s.name, value: s.total, itemStyle: { color: PALETTE[i % PALETTE.length] } }));
|
||||||
|
return {
|
||||||
|
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
legend: { bottom: 0, textStyle: { color: '#94a3b8', fontSize: 11 }, icon: 'circle' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie', radius: ['42%', '70%'], center: ['50%', '44%'],
|
||||||
|
itemStyle: { borderColor: 'rgba(2,6,23,0.6)', borderWidth: 2 },
|
||||||
|
label: { show: false }, labelLine: { show: false }, data
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const freqOption = computed<EChartsOption>(() => {
|
||||||
|
const stats = [...(overview.value?.by_freq || [])].sort((a, b) => a.flagged - b.flagged);
|
||||||
|
return {
|
||||||
|
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: 8, right: 24, top: 12, bottom: 8, containLabel: true },
|
||||||
|
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 } },
|
||||||
|
series: [{
|
||||||
|
type: 'bar', barWidth: '56%', data: stats.map(s => s.flagged),
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: [0, 4, 4, 0],
|
||||||
|
color: { type: 'linear', x: 0, y: 0, x2: 1, y2: 0, colorStops: [{ offset: 0, color: 'rgba(129,140,248,0.2)' }, { offset: 1, color: '#818cf8' }] }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 清单表格 ----------
|
||||||
|
const columns = computed<DataTableColumns<CellRow>>(() => [
|
||||||
|
{ title: overview.value?.rat === '5g' ? 'NCGI' : 'CGI', key: 'id', width: 180, ellipsis: { tooltip: true }, fixed: 'left' },
|
||||||
|
{ title: '小区名称', key: 'name', minWidth: 160, ellipsis: { tooltip: true } },
|
||||||
|
{ title: '制式', key: 'system', width: 70 },
|
||||||
|
{ title: '带宽', key: 'band', width: 70 },
|
||||||
|
{ title: '站型', key: 'station', width: 70 },
|
||||||
|
{ title: '频段', key: 'freq', width: 90, ellipsis: { tooltip: true } },
|
||||||
|
{ title: '上行%', key: 'ul', width: 80, render: r => `${r.ul}%` },
|
||||||
|
{ title: '下行%', key: 'dl', width: 80, render: r => `${r.dl}%` },
|
||||||
|
{ title: '日均流量', key: 'flow', width: 96, render: r => `${r.flow}` },
|
||||||
|
{ title: '用户数', key: 'users', width: 80 },
|
||||||
|
{
|
||||||
|
title: '问题', key: 'problem', width: 110, fixed: 'right',
|
||||||
|
render: r => h(NTag, { size: 'small', round: true, bordered: false, color: tagColor(r.problem) }, { default: () => r.problem })
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function tagColor(problem: string) {
|
||||||
|
const c = PROBLEM_COLOR[problem] || '#64748b';
|
||||||
|
return { color: 'rgba(148,163,184,0.12)', textColor: c, borderColor: 'transparent' };
|
||||||
|
}
|
||||||
|
function problemClass(problem: string) {
|
||||||
|
if (problem === '高负荷') return 'is-rose';
|
||||||
|
if (problem === '高流量预警') return 'is-amber';
|
||||||
|
if (problem === '利用率预警') return 'is-blue';
|
||||||
|
return 'is-slate';
|
||||||
|
}
|
||||||
|
function rowProps(row: CellRow) {
|
||||||
|
return { class: 'cap-row', style: 'cursor:pointer', onClick: () => openDetail(row.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 详情 ----------
|
||||||
|
const detailProblem = computed(() => detailValue('高负荷问题'));
|
||||||
|
const detailSuggestion = computed(() => detailValue('优化建议'));
|
||||||
|
const detailMetrics = computed(() => {
|
||||||
|
const d = detail.value;
|
||||||
|
if (!d) return [] as { label: string; value: string }[];
|
||||||
|
const det = d.detail;
|
||||||
|
const pct = (key: string) => { const v = det[key]; return v === '' || v === null || v === undefined ? '-' : `${(Number(v) * 100).toFixed(1)}%`; };
|
||||||
|
const raw = (key: string) => { const v = det[key]; return v === '' || v === null || v === undefined ? '-' : String(v); };
|
||||||
|
return [
|
||||||
|
{ label: d.labels.ul, value: pct(d.ul_field) },
|
||||||
|
{ label: d.labels.dl, value: pct(d.dl_field) },
|
||||||
|
{ label: '日均流量(GB)', value: raw(d.flow_field) },
|
||||||
|
{ label: '用户数', value: raw(d.users_field) },
|
||||||
|
{ label: '小区功率', value: raw('功率') },
|
||||||
|
{ label: '扇区', value: raw('扇区') },
|
||||||
|
{ label: '物理站', value: raw('物理站') },
|
||||||
|
{ label: '是否高负荷', value: raw('是否高负荷小区') }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
function detailValue(key: string): string {
|
||||||
|
const v = detail.value?.detail?.[key];
|
||||||
|
return v === undefined || v === null ? '' : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数据加载 ----------
|
||||||
|
async function loadStatus() {
|
||||||
|
loadingStatus.value = true;
|
||||||
|
try {
|
||||||
|
const status = await apiGet<{ ready: boolean }>('/api/dashboard/status');
|
||||||
|
ready.value = status.ready;
|
||||||
|
if (ready.value) await loadAll();
|
||||||
|
} catch (error) {
|
||||||
|
ready.value = false;
|
||||||
|
message.error(error instanceof Error ? error.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
loadingStatus.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([loadOverview(), loadCells()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
loadingOverview.value = true;
|
||||||
|
try {
|
||||||
|
const data = await apiGet<Overview>(`/api/dashboard/overview?rat=${rat.value}`);
|
||||||
|
overview.value = data;
|
||||||
|
animateCards(data.summary);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '加载概览失败');
|
||||||
|
} finally {
|
||||||
|
loadingOverview.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCells() {
|
||||||
|
loadingCells.value = true;
|
||||||
|
try {
|
||||||
|
const qs = `rat=${rat.value}&problem=${encodeURIComponent(problemFilter.value)}&keyword=${encodeURIComponent(keyword.value.trim())}&page=${page.value}&page_size=${pageSize}`;
|
||||||
|
const data = await apiGet<{ items: CellRow[]; total: number }>(`/api/dashboard/cells?${qs}`);
|
||||||
|
cells.value = data.items;
|
||||||
|
cellTotal.value = data.total;
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '加载清单失败');
|
||||||
|
} finally {
|
||||||
|
loadingCells.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
detailVisible.value = true;
|
||||||
|
loadingDetail.value = true;
|
||||||
|
detail.value = null;
|
||||||
|
try {
|
||||||
|
detail.value = await apiGet<CellDetail>(`/api/dashboard/cell?rat=${rat.value}&id=${encodeURIComponent(id)}`);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '加载详情失败');
|
||||||
|
detailVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
loadingDetail.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchRat(value: Rat) {
|
||||||
|
if (rat.value === value) return;
|
||||||
|
rat.value = value;
|
||||||
|
page.value = 1;
|
||||||
|
problemFilter.value = '';
|
||||||
|
keyword.value = '';
|
||||||
|
void loadAll();
|
||||||
|
}
|
||||||
|
function setProblemFilter(value: string) {
|
||||||
|
if (problemFilter.value === value) return;
|
||||||
|
problemFilter.value = value;
|
||||||
|
page.value = 1;
|
||||||
|
void loadCells();
|
||||||
|
}
|
||||||
|
function searchCells() {
|
||||||
|
page.value = 1;
|
||||||
|
void loadCells();
|
||||||
|
}
|
||||||
|
function changePage(value: number) {
|
||||||
|
page.value = value;
|
||||||
|
void loadCells();
|
||||||
|
}
|
||||||
|
function reload() {
|
||||||
|
void loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadStatus);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cap-dashboard {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px 24px 32px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 12% -10%, rgba(56, 189, 248, 0.12), transparent 60%),
|
||||||
|
radial-gradient(1000px 600px at 100% 0%, rgba(129, 140, 248, 0.12), transparent 55%),
|
||||||
|
linear-gradient(180deg, #0b1220 0%, #070b15 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-empty-orb {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 50% 40%, rgba(56, 189, 248, 0.55), rgba(56, 189, 248, 0) 70%);
|
||||||
|
box-shadow: 0 0 60px rgba(56, 189, 248, 0.45);
|
||||||
|
animation: pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.cap-empty-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.cap-empty-sub {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 460px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||||
|
50% { transform: scale(1.12); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-body { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
.cap-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cap-title-wrap { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.cap-spark {
|
||||||
|
width: 6px; height: 26px; border-radius: 3px;
|
||||||
|
background: linear-gradient(180deg, #22d3ee, #818cf8);
|
||||||
|
box-shadow: 0 0 16px rgba(56, 189, 248, 0.7);
|
||||||
|
}
|
||||||
|
.cap-title { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: 1px; color: #f1f5f9; }
|
||||||
|
.cap-sub { font-size: 12px; color: #64748b; }
|
||||||
|
.cap-controls { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
.cap-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
.cap-seg.sm { padding: 2px; border-radius: 8px; }
|
||||||
|
.cap-seg-btn {
|
||||||
|
padding: 6px 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
.cap-seg.sm .cap-seg-btn { padding: 4px 12px; font-size: 12px; }
|
||||||
|
.cap-seg-btn:hover { color: #e2e8f0; }
|
||||||
|
.cap-seg-btn.active {
|
||||||
|
color: #061018;
|
||||||
|
background: linear-gradient(135deg, #22d3ee, #38bdf8);
|
||||||
|
box-shadow: 0 0 18px rgba(56, 189, 248, 0.45);
|
||||||
|
}
|
||||||
|
.cap-refresh {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 34px; height: 34px; border-radius: 9px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
color: #94a3b8; cursor: pointer; font-size: 17px;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
.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-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.cap-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
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);
|
||||||
|
overflow: hidden;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
animation: cardIn 0.5s ease both;
|
||||||
|
}
|
||||||
|
@keyframes cardIn { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.cap-card-icon {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 42px; height: 42px; flex: 0 0 auto;
|
||||||
|
border-radius: 11px; font-size: 22px;
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
}
|
||||||
|
.cap-card-body { min-width: 0; }
|
||||||
|
.cap-card-value { font-size: 26px; font-weight: 800; line-height: 1.1; color: #f8fafc; font-variant-numeric: tabular-nums; }
|
||||||
|
.cap-card-unit { font-size: 13px; font-weight: 600; margin-left: 3px; color: #94a3b8; }
|
||||||
|
.cap-card-label { margin-top: 4px; font-size: 12px; 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); }
|
||||||
|
.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-blue .cap-card-icon { color: #38bdf8; } .tone-blue .cap-card-glow { background: rgba(56, 189, 248, 0.32); }
|
||||||
|
.tone-amber .cap-card-icon { color: #fbbf24; } .tone-amber .cap-card-glow { background: rgba(251, 191, 36, 0.3); }
|
||||||
|
.tone-violet .cap-card-icon { color: #c084fc; } .tone-violet .cap-card-glow { background: rgba(192, 132, 252, 0.3); }
|
||||||
|
.tone-emerald .cap-card-icon { color: #34d399; } .tone-emerald .cap-card-glow { background: rgba(52, 211, 153, 0.3); }
|
||||||
|
.cap-card::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: currentColor; opacity: 0.5; }
|
||||||
|
.tone-cyan::after { color: #22d3ee; } .tone-rose::after { color: #fb7185; } .tone-blue::after { color: #38bdf8; }
|
||||||
|
.tone-amber::after { color: #fbbf24; } .tone-violet::after { color: #c084fc; } .tone-emerald::after { color: #34d399; }
|
||||||
|
|
||||||
|
/* 图表区 */
|
||||||
|
.cap-charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.cap-panel {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
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);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.span-2 { grid-column: span 2; } .span-3 { grid-column: span 3; } .span-4 { grid-column: span 4; }
|
||||||
|
.cap-panel-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
margin-bottom: 10px; font-size: 13px; font-weight: 600; color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.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-list { display: flex; flex-direction: column; }
|
||||||
|
.cap-list-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.cap-list-filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.cap-search { width: 220px; }
|
||||||
|
.cap-table { background: transparent; margin-top: 4px; }
|
||||||
|
.cap-pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
|
||||||
|
.cap-pager-total { font-size: 12px; color: #64748b; }
|
||||||
|
|
||||||
|
/* 详情抽屉 */
|
||||||
|
.cap-detail-head { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||||
|
.cap-detail-id { font-weight: 700; font-size: 15px; }
|
||||||
|
.cap-detail-name { font-size: 12px; color: var(--td-text-color-secondary, #94a3b8); overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cap-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.cap-detail-tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.cap-tag { padding: 3px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||||
|
.cap-tag.xs { padding: 1px 8px; font-size: 11px; }
|
||||||
|
.cap-tag.plain { background: rgba(148, 163, 184, 0.16); color: var(--td-text-color-secondary, #64748b); }
|
||||||
|
.cap-tag.is-rose { background: rgba(251, 113, 133, 0.16); color: #f43f5e; }
|
||||||
|
.cap-tag.is-amber { background: rgba(251, 191, 36, 0.16); color: #d97706; }
|
||||||
|
.cap-tag.is-blue { background: rgba(56, 189, 248, 0.16); color: #0284c7; }
|
||||||
|
.cap-tag.is-slate { background: rgba(100, 116, 139, 0.16); color: #64748b; }
|
||||||
|
.cap-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.cap-metric { padding: 10px 12px; border-radius: 10px; background: var(--td-bg-color-secondarycontainer, rgba(148, 163, 184, 0.08)); border: 1px solid var(--td-border-color-light, rgba(148, 163, 184, 0.16)); }
|
||||||
|
.cap-metric-label { font-size: 12px; color: var(--td-text-color-secondary, #94a3b8); }
|
||||||
|
.cap-metric-value { margin-top: 4px; font-size: 16px; font-weight: 700; }
|
||||||
|
.cap-suggest { padding: 12px 14px; border-radius: 12px; background: rgba(251, 191, 36, 0.08); border: 1px solid rgba(251, 191, 36, 0.3); }
|
||||||
|
.cap-suggest-head { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #d97706; margin-bottom: 6px; }
|
||||||
|
.cap-suggest-text { margin: 0; font-size: 13px; line-height: 1.7; color: var(--td-text-color-primary, #334155); }
|
||||||
|
.cap-sib-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.cap-sib-item { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--td-border-color-light, rgba(148, 163, 184, 0.16)); }
|
||||||
|
.cap-sib-main { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.cap-sib-id { font-weight: 600; font-size: 13px; }
|
||||||
|
.cap-sib-name { font-size: 12px; color: var(--td-text-color-secondary, #94a3b8); overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cap-sib-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--td-text-color-secondary, #94a3b8); }
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.cap-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.span-2, .span-3, .span-4 { grid-column: span 3; }
|
||||||
|
.cap-charts { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.cap-dashboard { padding: 14px; }
|
||||||
|
.cap-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.span-2, .span-3, .span-4 { grid-column: span 6; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="host" class="echart" :style="{ height: height || '300px' }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
const props = defineProps<{ option: echarts.EChartsOption; height?: string }>();
|
||||||
|
|
||||||
|
const host = ref<HTMLDivElement | null>(null);
|
||||||
|
let chart: echarts.ECharts | null = null;
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!host.value) return;
|
||||||
|
chart = echarts.init(host.value, undefined, { renderer: 'canvas' });
|
||||||
|
chart.setOption(props.option);
|
||||||
|
observer = new ResizeObserver(() => chart?.resize());
|
||||||
|
observer.observe(host.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.option,
|
||||||
|
option => {
|
||||||
|
chart?.setOption(option, true);
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
chart?.dispose();
|
||||||
|
chart = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.echart {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
const FileWorkflow = () => import('./components/FileWorkflow.vue');
|
const FileWorkflow = () => import('./components/FileWorkflow.vue');
|
||||||
|
const CapacityDashboard = () => import('./components/CapacityDashboard.vue');
|
||||||
const HistoryPanel = () => import('./components/HistoryPanel.vue');
|
const HistoryPanel = () => import('./components/HistoryPanel.vue');
|
||||||
const DatabasePanel = () => import('./components/DatabasePanel.vue');
|
const DatabasePanel = () => import('./components/DatabasePanel.vue');
|
||||||
const SettingsPanel = () => import('./components/SettingsPanel.vue');
|
const SettingsPanel = () => import('./components/SettingsPanel.vue');
|
||||||
@@ -9,6 +10,7 @@ const ScriptPanel = () => import('./components/ScriptPanel.vue');
|
|||||||
export const routes: RouteRecordRaw[] = [
|
export const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: { name: 'workflow' } },
|
{ path: '/', redirect: { name: 'workflow' } },
|
||||||
{ path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据处理' } },
|
{ path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据处理' } },
|
||||||
|
{ path: '/dashboard', name: 'dashboard', component: CapacityDashboard, meta: { title: '容量看板' } },
|
||||||
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
|
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
|
||||||
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
||||||
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
|
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
|
||||||
|
|||||||
Reference in New Issue
Block a user