feat: 容量看板配色统一、整页不滚动布局并新增问题小区清单导出

This commit is contained in:
2026-06-26 08:17:07 +08:00
parent 837a74a626
commit f3f03e33e0
3 changed files with 352 additions and 195 deletions
+57
View File
@@ -5,11 +5,17 @@
"""
from __future__ import annotations
import csv
from datetime import datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from app import state
from app.config import CACHE_DIR
from app.utils.files import remove_file_safely
from app.warehouse import make_warehouse
router = APIRouter(tags=["dashboard"])
@@ -228,6 +234,57 @@ def dashboard_cells(
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")
def dashboard_cell(rat: str = Query("4g"), id: str = Query(...)):
cfg = _rat(rat)
+7
View File
@@ -917,3 +917,10 @@ CellData 处理日志细化(`app/services/cell_data.py`):
- 前端:新增依赖 `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 个)。
## 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` 通过。
+288 -195
View File
@@ -1,88 +1,92 @@
<template>
<div class="cap-dashboard">
<!-- 加载中 -->
<div v-if="loadingStatus" class="cap-center">
<n-spin size="large" />
</div>
<n-config-provider :theme="darkTheme" :theme-overrides="naiveDark">
<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-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 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>
<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 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>
<div class="cap-card-glow"></div>
<button class="cap-refresh" type="button" :disabled="loadingOverview" title="刷新" @click="reload">
<n-icon><RefreshOutline /></n-icon>
</button>
</div>
</section>
</header>
<!-- 图表区 -->
<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>
<div class="cap-spin">
<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">
<div class="cap-panel-head"><span class="bar"></span>负荷问题分布</div>
<e-chart :option="problemOption" :height="chartHeight" />
</div>
<div class="cap-panel">
<div class="cap-panel-head"><span class="bar"></span>下行利用率分布({{ overview?.labels.dl }})</div>
<e-chart :option="histOption" :height="chartHeight" />
</div>
<div class="cap-panel">
<div class="cap-panel-head"><span class="bar"></span>站型分布</div>
<e-chart :option="stationOption" :height="chartHeight" />
</div>
<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>
<e-chart :option="freqOption" :height="chartHeight" />
</div>
</section>
</n-spin>
</div>
<!-- 高负荷 / 预警 清单 -->
<section class="cap-panel cap-list">
@@ -102,6 +106,7 @@
<n-input
v-model:value="keyword"
class="cap-search"
size="small"
clearable
placeholder="CGI / 小区名称"
@keyup.enter="searchCells"
@@ -110,89 +115,100 @@
<template #prefix><n-icon><SearchOutline /></n-icon></template>
</n-input>
<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>
<n-data-table
class="cap-table"
:columns="columns"
:data="cells"
:loading="loadingCells"
:bordered="false"
:scroll-x="1180"
:row-props="rowProps"
/>
<div class="cap-table-wrap">
<n-data-table
class="cap-table"
size="small"
:columns="columns"
:data="cells"
:loading="loadingCells"
:bordered="false"
:single-line="false"
:scroll-x="1180"
:row-props="rowProps"
flex-height
style="height: 100%"
/>
</div>
<div class="cap-pager">
<span class="cap-pager-total">共 {{ cellTotal }} 个问题小区</span>
<n-pagination
:page="page"
:page-count="pageCount"
:page-size="pageSize"
size="small"
@update:page="changePage"
/>
</div>
</section>
</n-spin>
</div>
</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>
<!-- 小区详情抽屉 -->
<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>
<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-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 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 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>
</div>
</n-drawer-content>
</n-drawer>
</div>
</n-drawer-content>
</n-drawer>
</div>
</n-config-provider>
</template>
<script setup lang="ts">
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 { EChartsOption } from 'echarts';
import {
@@ -200,6 +216,7 @@ import {
AnalyticsOutline,
BulbOutline,
CellularOutline,
DownloadOutline,
FlashOutline,
PulseOutline,
RefreshOutline,
@@ -207,7 +224,7 @@ import {
TrendingUpOutline
} from '@vicons/ionicons5';
import { apiGet } from '../api/client';
import { apiGet, downloadGet } from '../api/client';
import EChart from './EChart.vue';
type Rat = '4g' | '5g';
@@ -237,6 +254,53 @@ interface CellDetail {
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 ready = ref(false);
const rat = ref<Rat>('4g');
@@ -255,6 +319,7 @@ const pageSize = 20;
const loadingCells = ref(false);
const problemFilter = ref('');
const keyword = ref('');
const exporting = ref(false);
const problemFilters = [
{ 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) {
detailVisible.value = true;
loadingDetail.value = true;
@@ -573,8 +653,10 @@ onMounted(loadStatus);
position: relative;
height: 100%;
min-height: 0;
overflow: auto;
padding: 20px 24px 32px;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 14px 18px;
color: #e2e8f0;
background:
radial-gradient(1200px 600px at 12% -10%, rgba(56, 189, 248, 0.12), transparent 60%),
@@ -587,8 +669,8 @@ onMounted(loadStatus);
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 320px;
flex: 1;
min-height: 0;
gap: 14px;
}
@@ -620,9 +702,10 @@ onMounted(loadStatus);
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 {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
@@ -631,11 +714,11 @@ onMounted(loadStatus);
}
.cap-title-wrap { display: flex; align-items: center; gap: 12px; }
.cap-spark {
width: 6px; height: 26px; border-radius: 3px;
width: 6px; height: 24px; 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-title { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: 1px; color: #f1f5f9; }
.cap-sub { font-size: 12px; color: #64748b; }
.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-btn {
padding: 6px 18px;
padding: 5px 16px;
border: 0;
border-radius: 8px;
background: transparent;
@@ -667,28 +750,33 @@ onMounted(loadStatus);
}
.cap-refresh {
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);
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;
}
.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; }
/* n-spin 包裹卡片与图表(固定高度区域) */
.cap-spin { flex: 0 0 auto; }
.cap-spin :deep(.n-spin-content) { display: flex; flex-direction: column; gap: 12px; }
/* 汇总卡片 */
.cap-cards {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
gap: 12px;
}
.cap-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 14px;
gap: 11px;
padding: 11px 13px;
border-radius: 13px;
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;
@@ -698,14 +786,14 @@ onMounted(loadStatus);
@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;
width: 38px; height: 38px; flex: 0 0 auto;
border-radius: 10px; font-size: 20px;
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-value { font-size: 22px; font-weight: 800; line-height: 1.15; color: #f8fafc; font-variant-numeric: tabular-nums; }
.cap-card-unit { font-size: 12px; font-weight: 600; margin-left: 3px; color: #94a3b8; }
.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); }
.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); }
@@ -717,71 +805,76 @@ onMounted(loadStatus);
.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 {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.cap-panel {
padding: 14px 16px;
border-radius: 14px;
padding: 12px 14px;
border-radius: 13px;
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;
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 .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 { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; padding-bottom: 10px; }
.cap-list-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
.cap-list-head .cap-panel-head { margin-bottom: 0; }
.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-search { width: 200px; }
.cap-table-wrap { flex: 1 1 0; min-height: 0; }
.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-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-id { font-weight: 700; font-size: 15px; color: #f1f5f9; }
.cap-detail-name { font-size: 12px; color: #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-tag.plain { background: rgba(148, 163, 184, 0.16); color: #94a3b8; }
.cap-tag.is-rose { background: rgba(251, 113, 133, 0.18); color: #fb7185; }
.cap-tag.is-amber { background: rgba(251, 191, 36, 0.18); color: #fbbf24; }
.cap-tag.is-blue { background: rgba(56, 189, 248, 0.18); color: #38bdf8; }
.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-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-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: #94a3b8; }
.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.1); border: 1px solid rgba(251, 191, 36, 0.32); }
.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: #e2e8f0; }
.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-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); }
.cap-sib-id { font-weight: 600; font-size: 13px; color: #e2e8f0; }
.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: #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: 1366px) {
.cap-card-value { font-size: 20px; }
.cap-card-icon { width: 34px; height: 34px; font-size: 18px; }
}
@media (max-width: 760px) {
.cap-dashboard { padding: 14px; }
@media (max-width: 1100px) {
.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)); }
.span-2, .span-3, .span-4 { grid-column: span 6; }
.cap-charts { grid-template-columns: minmax(0, 1fr); }
}
</style>