feat: 容量看板配色统一、整页不滚动布局并新增问题小区清单导出
This commit is contained in:
@@ -5,11 +5,17 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from app import state
|
from app import state
|
||||||
|
from app.config import CACHE_DIR
|
||||||
|
from app.utils.files import remove_file_safely
|
||||||
from app.warehouse import make_warehouse
|
from app.warehouse import make_warehouse
|
||||||
|
|
||||||
router = APIRouter(tags=["dashboard"])
|
router = APIRouter(tags=["dashboard"])
|
||||||
@@ -228,6 +234,57 @@ def dashboard_cells(
|
|||||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard/export")
|
||||||
|
def dashboard_export(rat: str = Query("4g"), problem: str = Query(""), keyword: 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']}`"
|
||||||
|
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)
|
||||||
|
|
||||||
|
rows = _rows(db, (
|
||||||
|
f"SELECT `{cfg['id']}` id, IFNULL(`{cfg['name']}`,'') name, IFNULL(`制式`,'') sys,"
|
||||||
|
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(`优化建议`,'') suggestion"
|
||||||
|
f" FROM {t}{where_sql}"
|
||||||
|
f" ORDER BY FIELD(`高负荷问题`,'高负荷','高流量预警','利用率预警'), {dl} DESC"
|
||||||
|
))
|
||||||
|
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"问题小区清单_{rat.lower()}_{timestamp}.csv"
|
||||||
|
filepath = CACHE_DIR / filename
|
||||||
|
header = [cfg["id"], "小区名称", "制式", "带宽", "站型", "频段",
|
||||||
|
"上行利用率(%)", "下行利用率(%)", "日均流量(GB)", "用户数", "高负荷问题", "优化建议"]
|
||||||
|
try:
|
||||||
|
with filepath.open("w", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow(header)
|
||||||
|
for r in rows:
|
||||||
|
writer.writerow([
|
||||||
|
r.get("id"), r.get("name"), r.get("sys"), r.get("band"), r.get("station"),
|
||||||
|
r.get("freq"), r.get("ul"), r.get("dl"), r.get("flow"), r.get("users"),
|
||||||
|
r.get("problem"), r.get("suggestion"),
|
||||||
|
])
|
||||||
|
except Exception:
|
||||||
|
remove_file_safely(filepath)
|
||||||
|
raise
|
||||||
|
return FileResponse(
|
||||||
|
path=str(filepath), filename=filename, media_type="text/csv",
|
||||||
|
background=BackgroundTask(remove_file_safely, filepath),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/dashboard/cell")
|
@router.get("/api/dashboard/cell")
|
||||||
def dashboard_cell(rat: str = Query("4g"), id: str = Query(...)):
|
def dashboard_cell(rat: str = Query("4g"), id: str = Query(...)):
|
||||||
cfg = _rat(rat)
|
cfg = _rat(rat)
|
||||||
|
|||||||
@@ -917,3 +917,10 @@ CellData 处理日志细化(`app/services/cell_data.py`):
|
|||||||
- 前端:新增依赖 `echarts`;`components/EChart.vue`(echarts 封装,ResizeObserver 自适应 + dispose);`components/CapacityDashboard.vue`(科技感大屏:4G/5G 分段切换、6 张汇总卡片含数字滚动、6 个图表(环形/直方/分组柱/饼/横向柱)、问题小区清单(筛选+分页+点击行打开)、右侧详情抽屉(关键指标 + 优化建议 + 同扇区兄弟小区))。无结果表时横纵居中「请先进行数据处理」。
|
- 前端:新增依赖 `echarts`;`components/EChart.vue`(echarts 封装,ResizeObserver 自适应 + dispose);`components/CapacityDashboard.vue`(科技感大屏:4G/5G 分段切换、6 张汇总卡片含数字滚动、6 个图表(环形/直方/分组柱/饼/横向柱)、问题小区清单(筛选+分页+点击行打开)、右侧详情抽屉(关键指标 + 优化建议 + 同扇区兄弟小区))。无结果表时横纵居中「请先进行数据处理」。
|
||||||
- 路由 `/dashboard`(name=`dashboard`,懒加载);侧边菜单「容量看板」置于「数据处理」下方(`AppShell` menuKeys/menuOptions + `StatsChartOutline`)。看板自带深色科技风(径向辉光/玻璃拟态/霓虹色),不随应用明暗主题;图表 echarts 自定义深色配色。
|
- 路由 `/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 个)。
|
- 验证:`npm run build`(vue-tsc)通过(CapacityDashboard 懒加载 chunk≈1.16MB 含 echarts);后端 `compileall` 通过;各接口 SQL 经 MCP 对真实数据验证(4G 高负荷 792 / 5G 46,同扇区邻区 6–8 个)。
|
||||||
|
|
||||||
|
## 2026-06-26:容量看板配色统一 + 整页不滚动 + 清单导出
|
||||||
|
|
||||||
|
- 组件配色统一:`CapacityDashboard.vue` 整体用 `<n-config-provider :theme="darkTheme" :theme-overrides="naiveDark">` 包裹,强制看板内所有 Naive 组件(数据表/搜索框/分页/抽屉/Empty/按钮)走深色 + 青色(`#22d3ee`)强调色,解决浅色应用主题下表格/搜索/分页与深色大屏不匹配的问题。`naiveDark` 覆盖 DataTable(透明 td、半透明 th、淡边框)、Input(深色半透明底)、Pagination(深色项+青色激活)、Drawer(`#0c1422`)、Empty。抽屉内自定义 `.cap-*` 样式由 `var(--td-*)` 改为固定深色值,不再跟随应用主题。
|
||||||
|
- 整页不滚动:`.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` 态,清单为空时禁用)。
|
||||||
|
- 验证:`frontend` `vue-tsc --noEmit` 通过;后端 `py_compile dashboard.py` 通过。
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<n-config-provider :theme="darkTheme" :theme-overrides="naiveDark">
|
||||||
<div class="cap-dashboard">
|
<div class="cap-dashboard">
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<div v-if="loadingStatus" class="cap-center">
|
<div v-if="loadingStatus" class="cap-center">
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="cap-spin">
|
||||||
<n-spin :show="loadingOverview">
|
<n-spin :show="loadingOverview">
|
||||||
<!-- 汇总卡片 -->
|
<!-- 汇总卡片 -->
|
||||||
<section class="cap-cards">
|
<section class="cap-cards">
|
||||||
@@ -56,33 +58,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 图表区 -->
|
<!-- 图表区:两行三列 -->
|
||||||
<section class="cap-charts">
|
<section class="cap-charts">
|
||||||
<div class="cap-panel span-2">
|
<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="problemOption" height="280px" />
|
<e-chart :option="problemOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel span-4">
|
<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.dl }})</div>
|
||||||
<e-chart :option="histOption" height="280px" />
|
<e-chart :option="histOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel span-3">
|
<div class="cap-panel">
|
||||||
<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>
|
<div class="cap-panel-head"><span class="bar"></span>站型分布</div>
|
||||||
<e-chart :option="stationOption" height="260px" />
|
<e-chart :option="stationOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-panel span-4">
|
<div class="cap-panel">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>制式分布(总数 / 高负荷)</div>
|
||||||
|
<e-chart :option="systemOption" :height="chartHeight" />
|
||||||
|
</div>
|
||||||
|
<div class="cap-panel">
|
||||||
|
<div class="cap-panel-head"><span class="bar"></span>带宽分布(总数 / 高负荷)</div>
|
||||||
|
<e-chart :option="bandOption" :height="chartHeight" />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
<e-chart :option="freqOption" height="260px" />
|
<e-chart :option="freqOption" :height="chartHeight" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 高负荷 / 预警 清单 -->
|
<!-- 高负荷 / 预警 清单 -->
|
||||||
<section class="cap-panel cap-list">
|
<section class="cap-panel cap-list">
|
||||||
@@ -102,6 +106,7 @@
|
|||||||
<n-input
|
<n-input
|
||||||
v-model:value="keyword"
|
v-model:value="keyword"
|
||||||
class="cap-search"
|
class="cap-search"
|
||||||
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
placeholder="CGI / 小区名称"
|
placeholder="CGI / 小区名称"
|
||||||
@keyup.enter="searchCells"
|
@keyup.enter="searchCells"
|
||||||
@@ -110,28 +115,38 @@
|
|||||||
<template #prefix><n-icon><SearchOutline /></n-icon></template>
|
<template #prefix><n-icon><SearchOutline /></n-icon></template>
|
||||||
</n-input>
|
</n-input>
|
||||||
<n-button size="small" type="primary" ghost @click="searchCells">查询</n-button>
|
<n-button size="small" type="primary" ghost @click="searchCells">查询</n-button>
|
||||||
|
<n-button size="small" tertiary :loading="exporting" :disabled="!cellTotal" @click="exportCells">
|
||||||
|
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||||
|
导出
|
||||||
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cap-table-wrap">
|
||||||
<n-data-table
|
<n-data-table
|
||||||
class="cap-table"
|
class="cap-table"
|
||||||
|
size="small"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="cells"
|
:data="cells"
|
||||||
:loading="loadingCells"
|
:loading="loadingCells"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
|
:single-line="false"
|
||||||
:scroll-x="1180"
|
:scroll-x="1180"
|
||||||
:row-props="rowProps"
|
:row-props="rowProps"
|
||||||
|
flex-height
|
||||||
|
style="height: 100%"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div class="cap-pager">
|
<div class="cap-pager">
|
||||||
<span class="cap-pager-total">共 {{ cellTotal }} 个问题小区</span>
|
<span class="cap-pager-total">共 {{ cellTotal }} 个问题小区</span>
|
||||||
<n-pagination
|
<n-pagination
|
||||||
:page="page"
|
:page="page"
|
||||||
:page-count="pageCount"
|
:page-count="pageCount"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
size="small"
|
||||||
@update:page="changePage"
|
@update:page="changePage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</n-spin>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 小区详情抽屉 -->
|
<!-- 小区详情抽屉 -->
|
||||||
@@ -188,11 +203,12 @@
|
|||||||
</n-drawer-content>
|
</n-drawer-content>
|
||||||
</n-drawer>
|
</n-drawer>
|
||||||
</div>
|
</div>
|
||||||
|
</n-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, reactive, ref, type Component } from 'vue';
|
import { computed, h, onMounted, reactive, ref, type Component } from 'vue';
|
||||||
import { NTag, useMessage } from 'naive-ui';
|
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 {
|
||||||
@@ -200,6 +216,7 @@ import {
|
|||||||
AnalyticsOutline,
|
AnalyticsOutline,
|
||||||
BulbOutline,
|
BulbOutline,
|
||||||
CellularOutline,
|
CellularOutline,
|
||||||
|
DownloadOutline,
|
||||||
FlashOutline,
|
FlashOutline,
|
||||||
PulseOutline,
|
PulseOutline,
|
||||||
RefreshOutline,
|
RefreshOutline,
|
||||||
@@ -207,7 +224,7 @@ import {
|
|||||||
TrendingUpOutline
|
TrendingUpOutline
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
|
|
||||||
import { apiGet } from '../api/client';
|
import { apiGet, downloadGet } from '../api/client';
|
||||||
import EChart from './EChart.vue';
|
import EChart from './EChart.vue';
|
||||||
|
|
||||||
type Rat = '4g' | '5g';
|
type Rat = '4g' | '5g';
|
||||||
@@ -237,6 +254,53 @@ interface CellDetail {
|
|||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
|
||||||
|
// 强制看板内 Naive 组件走深色配色,统一科技感主题
|
||||||
|
const naiveDark = {
|
||||||
|
common: {
|
||||||
|
primaryColor: '#22d3ee',
|
||||||
|
primaryColorHover: '#38bdf8',
|
||||||
|
primaryColorPressed: '#0891b2',
|
||||||
|
primaryColorSuppl: '#38bdf8',
|
||||||
|
borderRadius: '8px'
|
||||||
|
},
|
||||||
|
DataTable: {
|
||||||
|
thColor: 'rgba(30, 41, 59, 0.55)',
|
||||||
|
thColorHover: 'rgba(30, 41, 59, 0.85)',
|
||||||
|
thTextColor: '#cbd5e1',
|
||||||
|
thFontWeight: '600',
|
||||||
|
tdColor: 'transparent',
|
||||||
|
tdColorHover: 'rgba(56, 189, 248, 0.08)',
|
||||||
|
tdTextColor: '#dbe4f0',
|
||||||
|
borderColor: 'rgba(148, 163, 184, 0.12)',
|
||||||
|
loadingColor: 'rgba(8, 14, 26, 0.6)'
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
color: 'rgba(15, 23, 42, 0.45)',
|
||||||
|
colorFocus: 'rgba(15, 23, 42, 0.7)',
|
||||||
|
border: '1px solid rgba(148, 163, 184, 0.18)',
|
||||||
|
borderHover: '1px solid rgba(56, 189, 248, 0.5)',
|
||||||
|
borderFocus: '1px solid rgba(56, 189, 248, 0.7)',
|
||||||
|
boxShadowFocus: '0 0 0 2px rgba(56, 189, 248, 0.18)',
|
||||||
|
textColor: '#e2e8f0',
|
||||||
|
placeholderColor: '#64748b'
|
||||||
|
},
|
||||||
|
Pagination: {
|
||||||
|
itemColor: 'rgba(15, 23, 42, 0.4)',
|
||||||
|
itemColorHover: 'rgba(56, 189, 248, 0.12)',
|
||||||
|
itemColorPressed: 'rgba(56, 189, 248, 0.18)',
|
||||||
|
itemColorActive: 'rgba(56, 189, 248, 0.16)',
|
||||||
|
itemBorder: '1px solid rgba(148, 163, 184, 0.16)',
|
||||||
|
itemBorderActive: '1px solid #22d3ee',
|
||||||
|
itemTextColor: '#94a3b8',
|
||||||
|
itemTextColorHover: '#e2e8f0',
|
||||||
|
itemTextColorActive: '#22d3ee'
|
||||||
|
},
|
||||||
|
Drawer: { color: '#0c1422' },
|
||||||
|
Empty: { textColor: '#64748b', iconColor: '#475569' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartHeight = 'clamp(136px, 16vh, 184px)';
|
||||||
|
|
||||||
const loadingStatus = ref(true);
|
const loadingStatus = ref(true);
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
const rat = ref<Rat>('4g');
|
const rat = ref<Rat>('4g');
|
||||||
@@ -255,6 +319,7 @@ const pageSize = 20;
|
|||||||
const loadingCells = ref(false);
|
const loadingCells = ref(false);
|
||||||
const problemFilter = ref('');
|
const problemFilter = ref('');
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
|
const exporting = ref(false);
|
||||||
const problemFilters = [
|
const problemFilters = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '' },
|
||||||
{ label: '高负荷', value: '高负荷' },
|
{ label: '高负荷', value: '高负荷' },
|
||||||
@@ -525,6 +590,21 @@ async function loadCells() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportCells() {
|
||||||
|
if (!cellTotal.value) return;
|
||||||
|
exporting.value = true;
|
||||||
|
try {
|
||||||
|
const qs = `rat=${rat.value}&problem=${encodeURIComponent(problemFilter.value)}&keyword=${encodeURIComponent(keyword.value.trim())}`;
|
||||||
|
const filename = `问题小区清单_${rat.value.toUpperCase()}.csv`;
|
||||||
|
const result = await downloadGet(`/api/dashboard/export?${qs}`, filename);
|
||||||
|
if (result.saved) message.success('问题小区清单已导出');
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
loadingDetail.value = true;
|
loadingDetail.value = true;
|
||||||
@@ -573,8 +653,10 @@ onMounted(loadStatus);
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
padding: 20px 24px 32px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px 18px;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(1200px 600px at 12% -10%, rgba(56, 189, 248, 0.12), transparent 60%),
|
radial-gradient(1200px 600px at 12% -10%, rgba(56, 189, 248, 0.12), transparent 60%),
|
||||||
@@ -587,8 +669,8 @@ onMounted(loadStatus);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,9 +702,10 @@ onMounted(loadStatus);
|
|||||||
50% { transform: scale(1.12); opacity: 1; }
|
50% { transform: scale(1.12); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.cap-body { display: flex; flex-direction: column; gap: 18px; }
|
.cap-body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
.cap-topbar {
|
.cap-topbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -631,11 +714,11 @@ onMounted(loadStatus);
|
|||||||
}
|
}
|
||||||
.cap-title-wrap { display: flex; align-items: center; gap: 12px; }
|
.cap-title-wrap { display: flex; align-items: center; gap: 12px; }
|
||||||
.cap-spark {
|
.cap-spark {
|
||||||
width: 6px; height: 26px; border-radius: 3px;
|
width: 6px; height: 24px; border-radius: 3px;
|
||||||
background: linear-gradient(180deg, #22d3ee, #818cf8);
|
background: linear-gradient(180deg, #22d3ee, #818cf8);
|
||||||
box-shadow: 0 0 16px rgba(56, 189, 248, 0.7);
|
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-title { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: 1px; color: #f1f5f9; }
|
||||||
.cap-sub { font-size: 12px; color: #64748b; }
|
.cap-sub { font-size: 12px; color: #64748b; }
|
||||||
.cap-controls { display: flex; align-items: center; gap: 12px; }
|
.cap-controls { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
@@ -648,7 +731,7 @@ onMounted(loadStatus);
|
|||||||
}
|
}
|
||||||
.cap-seg.sm { padding: 2px; border-radius: 8px; }
|
.cap-seg.sm { padding: 2px; border-radius: 8px; }
|
||||||
.cap-seg-btn {
|
.cap-seg-btn {
|
||||||
padding: 6px 18px;
|
padding: 5px 16px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -667,28 +750,33 @@ onMounted(loadStatus);
|
|||||||
}
|
}
|
||||||
.cap-refresh {
|
.cap-refresh {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
width: 34px; height: 34px; border-radius: 9px;
|
width: 32px; height: 32px; border-radius: 9px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: rgba(15, 23, 42, 0.6);
|
||||||
color: #94a3b8; cursor: pointer; font-size: 17px;
|
color: #94a3b8; cursor: pointer; font-size: 16px;
|
||||||
transition: all 0.18s ease;
|
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: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 包裹卡片与图表(固定高度区域) */
|
||||||
|
.cap-spin { flex: 0 0 auto; }
|
||||||
|
.cap-spin :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(6, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.cap-card {
|
.cap-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 11px;
|
||||||
padding: 16px;
|
padding: 11px 13px;
|
||||||
border-radius: 14px;
|
border-radius: 13px;
|
||||||
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;
|
||||||
@@ -698,14 +786,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: 42px; height: 42px; flex: 0 0 auto;
|
width: 38px; height: 38px; flex: 0 0 auto;
|
||||||
border-radius: 11px; font-size: 22px;
|
border-radius: 10px; font-size: 20px;
|
||||||
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: 26px; font-weight: 800; line-height: 1.1; color: #f8fafc; font-variant-numeric: tabular-nums; }
|
.cap-card-value { font-size: 22px; font-weight: 800; line-height: 1.15; color: #f8fafc; font-variant-numeric: tabular-nums; }
|
||||||
.cap-card-unit { font-size: 13px; font-weight: 600; margin-left: 3px; color: #94a3b8; }
|
.cap-card-unit { font-size: 12px; 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-label { margin-top: 2px; 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); }
|
.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); }
|
||||||
@@ -717,71 +805,76 @@ onMounted(loadStatus);
|
|||||||
.tone-cyan::after { color: #22d3ee; } .tone-rose::after { color: #fb7185; } .tone-blue::after { color: #38bdf8; }
|
.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; }
|
.tone-amber::after { color: #fbbf24; } .tone-violet::after { color: #c084fc; } .tone-emerald::after { color: #34d399; }
|
||||||
|
|
||||||
/* 图表区 */
|
/* 图表区:两行三列 */
|
||||||
.cap-charts {
|
.cap-charts {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.cap-panel {
|
.cap-panel {
|
||||||
padding: 14px 16px;
|
padding: 12px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 13px;
|
||||||
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);
|
||||||
}
|
}
|
||||||
.span-2 { grid-column: span 2; } .span-3 { grid-column: span 3; } .span-4 { grid-column: span 4; }
|
|
||||||
.cap-panel-head {
|
.cap-panel-head {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
margin-bottom: 10px; font-size: 13px; font-weight: 600; color: #cbd5e1;
|
margin-bottom: 6px; 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 { display: flex; flex-direction: column; }
|
.cap-list { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; padding-bottom: 10px; }
|
||||||
.cap-list-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-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 .cap-panel-head { margin-bottom: 0; }
|
||||||
.cap-list-filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.cap-list-filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
.cap-search { width: 220px; }
|
.cap-search { width: 200px; }
|
||||||
.cap-table { background: transparent; margin-top: 4px; }
|
.cap-table-wrap { flex: 1 1 0; min-height: 0; }
|
||||||
.cap-pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
|
.cap-table { height: 100%; background: transparent; }
|
||||||
|
.cap-pager { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; }
|
||||||
.cap-pager-total { font-size: 12px; color: #64748b; }
|
.cap-pager-total { font-size: 12px; color: #64748b; }
|
||||||
|
|
||||||
/* 详情抽屉 */
|
/* 详情抽屉(强制深色,避免跟随应用浅色主题) */
|
||||||
.cap-detail-head { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
.cap-detail-head { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||||
.cap-detail-id { font-weight: 700; font-size: 15px; }
|
.cap-detail-id { font-weight: 700; font-size: 15px; color: #f1f5f9; }
|
||||||
.cap-detail-name { font-size: 12px; color: var(--td-text-color-secondary, #94a3b8); overflow: hidden; text-overflow: ellipsis; }
|
.cap-detail-name { font-size: 12px; color: #94a3b8; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.cap-detail { display: flex; flex-direction: column; gap: 16px; }
|
.cap-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.cap-detail-tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
.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 { padding: 3px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||||
.cap-tag.xs { padding: 1px 8px; font-size: 11px; }
|
.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.plain { background: rgba(148, 163, 184, 0.16); color: #94a3b8; }
|
||||||
.cap-tag.is-rose { background: rgba(251, 113, 133, 0.16); color: #f43f5e; }
|
.cap-tag.is-rose { background: rgba(251, 113, 133, 0.18); color: #fb7185; }
|
||||||
.cap-tag.is-amber { background: rgba(251, 191, 36, 0.16); color: #d97706; }
|
.cap-tag.is-amber { background: rgba(251, 191, 36, 0.18); color: #fbbf24; }
|
||||||
.cap-tag.is-blue { background: rgba(56, 189, 248, 0.16); color: #0284c7; }
|
.cap-tag.is-blue { background: rgba(56, 189, 248, 0.18); color: #38bdf8; }
|
||||||
.cap-tag.is-slate { background: rgba(100, 116, 139, 0.16); color: #64748b; }
|
.cap-tag.is-slate { background: rgba(100, 116, 139, 0.18); color: #94a3b8; }
|
||||||
.cap-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
.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 { padding: 10px 12px; border-radius: 10px; background: rgba(148, 163, 184, 0.08); border: 1px solid rgba(148, 163, 184, 0.16); }
|
||||||
.cap-metric-label { font-size: 12px; color: var(--td-text-color-secondary, #94a3b8); }
|
.cap-metric-label { font-size: 12px; color: #94a3b8; }
|
||||||
.cap-metric-value { margin-top: 4px; font-size: 16px; font-weight: 700; }
|
.cap-metric-value { margin-top: 4px; font-size: 16px; font-weight: 700; color: #f1f5f9; }
|
||||||
.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 { padding: 12px 14px; border-radius: 12px; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.32); }
|
||||||
.cap-suggest-head { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #d97706; margin-bottom: 6px; }
|
.cap-suggest-head { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #fbbf24; margin-bottom: 6px; }
|
||||||
.cap-suggest-text { margin: 0; font-size: 13px; line-height: 1.7; color: var(--td-text-color-primary, #334155); }
|
.cap-suggest-text { margin: 0; font-size: 13px; line-height: 1.7; color: #e2e8f0; }
|
||||||
.cap-sib-list { display: flex; flex-direction: column; gap: 8px; }
|
.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-item { padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(148, 163, 184, 0.16); background: rgba(148, 163, 184, 0.05); }
|
||||||
.cap-sib-main { display: flex; align-items: baseline; gap: 8px; }
|
.cap-sib-main { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.cap-sib-id { font-weight: 600; font-size: 13px; }
|
.cap-sib-id { font-weight: 600; font-size: 13px; color: #e2e8f0; }
|
||||||
.cap-sib-name { font-size: 12px; color: var(--td-text-color-secondary, #94a3b8); overflow: hidden; text-overflow: ellipsis; }
|
.cap-sib-name { font-size: 12px; color: #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); }
|
.cap-sib-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 12px; color: #94a3b8; }
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1366px) {
|
||||||
.cap-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.cap-card-value { font-size: 20px; }
|
||||||
.span-2, .span-3, .span-4 { grid-column: span 3; }
|
.cap-card-icon { width: 34px; height: 34px; font-size: 18px; }
|
||||||
.cap-charts { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 1100px) {
|
||||||
.cap-dashboard { padding: 14px; }
|
.cap-dashboard { overflow: auto; }
|
||||||
|
.cap-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.cap-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
.cap-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.cap-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.span-2, .span-3, .span-4 { grid-column: span 6; }
|
.cap-charts { grid-template-columns: minmax(0, 1fr); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user