feat: 整合运行诊断和配置界面

This commit is contained in:
chuan
2026-08-10 15:18:37 +08:00
parent 28fa69e614
commit f6e5c1ceca
6 changed files with 98 additions and 96 deletions
+4 -2
View File
@@ -178,8 +178,8 @@
- [x] 保留种子详情顶部结构并使用 reka-ui 数字分页重构文件条目
- [x] 支持用户选择并持久化文件列表每页数量
- [x] 统一搜索结果数字分页并支持用户选择每页数量
- [x] 接入采集索引持久化和验证运行状态展示
- [x] 实现运行状态每秒刷新点击外部关闭和浏览器持久化明暗主题
- [x] 采集索引持久化和验证运行状态集中到系统诊断页并仅在页面打开时每秒刷新
- [x] 实现浏览器持久化明暗主题
- [x] 为加载空结果接口错误和失败重试提供明确界面状态
- [x] 将生产静态资源交给 Axum 提供并支持单页回退
- [x] 提供 Windows 一键启动后端和 Web 开发服务的脚本
@@ -345,6 +345,8 @@
- [x] 将 HTTP 请求并发客户端错误服务端错误和延迟分布写入诊断历史
- [x] 增加配置查询完整校验原子保存并发修订和统一重启提示
- [x] 增加 Web 诊断页和配置管理页
- [x] 使用页签拆分配置分类并隐藏底层配置存储位置
- [ ] 将用户可编辑配置的持久化适配器从 TOML 迁移到 SQLite
完成二十四小时持续运行并继续观察私有内存 Metadata 成功率候选队列深度和每条成功 Metadata 的网络成本
+4 -4
View File
@@ -58,11 +58,11 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
- 基于 reka-ui 的数字分页文件列表和浏览器持久化每页数量选择
- 相同内容的不同 infohash 变体展示
- 磁力链接打开和复制
- DHT 采集 索引 持久化和验证运行状态
- 每秒自动刷新的运行状态和点击外部关闭
- 独立进程 RocksDB Tantivy DHT 和队列诊断页面
- 在系统诊断页统一展示 DHT 采集 索引 持久化和验证运行状态
- 仅在系统诊断页打开时每秒刷新服务状态
- 独立展示进程 RocksDB Tantivy DHT 和队列诊断数据
- 使用懒加载 ECharts 展示内存存储压力 Metadata 吞吐以及 HTTP 请求错误趋势
- 按职责分组的完整配置查看编辑校验保存和重启提示
- 使用 reka-ui 页签按职责分组的完整配置查看编辑校验保存和重启提示
- 搜索诊断配置使用互不冲突的单页路由
- 加载 空结果 接口错误 重试和移动端适配
+2 -50
View File
@@ -1,18 +1,11 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ChartLine, Database, Moon, Search, Settings, Sun, X } from '@lucide/vue'
import { onMounted, ref } from 'vue'
import { ChartLine, Database, Moon, Search, Settings, Sun } from '@lucide/vue'
import { RouterLink, RouterView } from 'vue-router'
import { Button } from '@/components/ui/button'
import { getStats } from '@/lib/api'
import { formatBytes } from '@/lib/format'
import type { ServiceStats } from '@/types/api'
const darkMode = ref(false)
const stats = ref<ServiceStats | null>(null)
const statsOpen = ref(false)
let statsTimer: number | null = null
let statsRequestActive = false
const navigation = [
{ to: '/', label: '搜索', icon: Search },
@@ -26,41 +19,8 @@ function toggleTheme() {
localStorage.setItem('dht-search-theme', darkMode.value ? 'dark' : 'light')
}
function diskStateLabel(state: ServiceStats['disk_state']): string {
if (state === 'normal') return '正常'
if (state === 'draining') return '正在排空'
return '只读保护'
}
function closeStatsOnOutsideClick(event: PointerEvent) {
if (statsOpen.value && event.target instanceof Element && !event.target.closest('[data-stats-panel]')) {
statsOpen.value = false
}
}
async function loadStats() {
if (statsRequestActive) return
statsRequestActive = true
try { stats.value = await getStats() } catch { stats.value = null } finally { statsRequestActive = false }
}
watch(statsOpen, (open) => {
if (statsTimer !== null) window.clearInterval(statsTimer)
statsTimer = null
if (open) {
void loadStats()
statsTimer = window.setInterval(() => void loadStats(), 1_000)
}
})
onMounted(() => {
darkMode.value = document.documentElement.classList.contains('dark')
document.addEventListener('pointerdown', closeStatsOnOutsideClick)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeStatsOnOutsideClick)
if (statsTimer !== null) window.clearInterval(statsTimer)
})
</script>
@@ -84,14 +44,6 @@ onBeforeUnmount(() => {
<div class="ml-auto flex items-center gap-1">
<Button size="icon" variant="ghost" :aria-label="darkMode ? '切换到浅色主题' : '切换到深色主题'" :title="darkMode ? '浅色主题' : '深色主题'" @click="toggleTheme"><Sun v-if="darkMode" /><Moon v-else /></Button>
<div class="relative" data-stats-panel>
<Button size="icon" variant="ghost" aria-label="运行状态" title="运行状态" @click="statsOpen = !statsOpen"><span class="relative"><Activity class="size-4" /><i class="absolute -right-0.5 -top-0.5 size-1.5 rounded-full" :class="!stats ? 'bg-muted-foreground' : stats.disk_state === 'normal' ? 'bg-emerald-500' : 'bg-amber-500'" /></span></Button>
<div v-if="statsOpen" class="absolute right-0 top-11 w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-xl">
<div class="mb-3 flex items-center"><p class="text-sm font-semibold">服务运行状态</p><button class="ml-auto" aria-label="关闭状态面板" @click="statsOpen = false"><X class="size-4" /></button></div>
<div v-if="stats" class="grid grid-cols-2 gap-3 text-xs"><div class="status-cell"><span>磁盘状态</span><b>{{ diskStateLabel(stats.disk_state) }}</b></div><div class="status-cell"><span>磁盘剩余</span><b>{{ stats.disk_available_bytes === null ? '未知' : formatBytes(stats.disk_available_bytes) }}</b></div><div class="status-cell"><span>DHT 节点</span><b>{{ stats.nodes.toLocaleString() }}</b></div><div class="status-cell"><span>已索引内容</span><b>{{ stats.indexed_documents.toLocaleString() }}</b></div><div class="status-cell"><span>获取成功</span><b>{{ stats.metadata_ok.toLocaleString() }}</b></div><div class="status-cell"><span>下载中</span><b>{{ stats.metadata_in_flight }}</b></div><div class="status-cell"><span>新收录</span><b>{{ stats.persistence_inserted.toLocaleString() }}</b></div><div class="status-cell"><span>已过滤</span><b>{{ stats.metadata_filtered.toLocaleString() }}</b></div><div class="status-cell"><span>验证成功</span><b>{{ stats.verification_succeeded.toLocaleString() }}</b></div></div>
<p v-else class="py-4 text-center text-xs text-muted-foreground">无法获取服务状态</p>
</div>
</div>
</div>
</div>
</header>
+27 -16
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AlertTriangle, Check, FileCog, LoaderCircle, RotateCcw, Save } from '@lucide/vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { onBeforeRouteLeave } from 'vue-router'
import { Button } from '@/components/ui/button'
@@ -103,6 +104,7 @@ const sections: ConfigSection[] = [
const snapshot = ref<ConfigSnapshot | null>(null)
const config = ref<AppConfigDto | null>(null)
const activeSection = ref(sections[0]?.title ?? '')
const original = ref('')
const loading = ref(true)
const saving = ref(false)
@@ -179,9 +181,9 @@ onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,
<template>
<main class="mx-auto max-w-6xl px-4 py-7 sm:px-6 sm:py-10">
<div class="mb-6 flex flex-wrap items-start gap-3">
<div><h1 class="text-xl font-semibold tracking-tight">配置管理</h1><p class="mt-1 text-sm text-muted-foreground">修改经过完整校验后原子保存到 TOML</p></div>
<div class="ml-auto flex gap-2"><Button :disabled="loading || saving" variant="outline" @click="load"><RotateCcw />重新加载</Button><Button :disabled="!dirty || saving" @click="save"><LoaderCircle v-if="saving" class="animate-spin" /><Save v-else />保存配置</Button></div>
<div class="mb-6 flex flex-wrap items-center gap-3">
<h1 class="text-xl font-semibold tracking-tight">配置管理</h1>
<div class="ml-auto flex items-center gap-2"><span v-if="dirty" class="hidden text-xs text-muted-foreground sm:inline">有未保存修改</span><Button :disabled="loading || saving" variant="outline" @click="load"><RotateCcw />重新加载</Button><Button :disabled="!dirty || saving" @click="save"><LoaderCircle v-if="saving" class="animate-spin" /><Save v-else />保存配置</Button></div>
</div>
<div v-if="snapshot?.restart_required" class="mb-4 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm"><AlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-500" /><div><p class="font-medium">配置已保存,需要重启服务后生效</p><p class="mt-1 text-muted-foreground">当前进程仍使用启动时的配置</p></div></div>
@@ -190,19 +192,28 @@ onBeforeRouteLeave(() => !dirty.value || window.confirm('配置尚未保存,
<div v-if="error" class="mb-4 rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{{ error }}</div>
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在读取配置</div>
<form v-else-if="config" class="space-y-4" @submit.prevent="save">
<section v-for="section in sections" :key="section.title" class="rounded-xl border bg-card shadow-sm">
<div class="border-b px-5 py-4"><h2 class="font-semibold">{{ section.title }}</h2><p class="mt-1 text-sm text-muted-foreground">{{ section.description }}</p></div>
<div class="grid gap-x-6 px-5 sm:grid-cols-2">
<label v-for="field in section.fields" :key="field.path" class="config-field" :class="field.type === 'boolean' ? 'flex-row items-center' : 'flex-col'">
<span class="min-w-0 flex-1"><b>{{ field.label }}</b><small>{{ field.description }}</small></span>
<input v-if="field.type === 'boolean'" class="size-4 accent-foreground" type="checkbox" :checked="Boolean(fieldValue(field.path))" @change="updateField(field, $event)" />
<select v-else-if="field.type === 'select'" class="config-input" :value="String(fieldValue(field.path))" @change="updateField(field, $event)"><option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</option></select>
<input v-else class="config-input" :type="field.type" :value="fieldValue(field.path) ?? ''" :min="field.type === 'number' ? 0 : undefined" step="1" @input="updateField(field, $event)" />
</label>
</div>
</section>
<div class="flex flex-wrap items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3 text-xs text-muted-foreground"><span class="truncate">配置文件{{ snapshot?.source }}</span><span v-if="dirty" class="ml-auto text-foreground">有未保存修改</span></div>
<form v-else-if="config" @submit.prevent="save">
<TabsRoot v-model="activeSection">
<TabsList class="flex w-full gap-1 overflow-x-auto rounded-xl bg-muted/60 p-1" aria-label="配置分类">
<TabsTrigger v-for="section in sections" :key="section.title" :value="section.title" class="h-9 shrink-0 rounded-lg px-3 text-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/30 data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm">
{{ section.title }}
</TabsTrigger>
</TabsList>
<TabsContent v-for="section in sections" :key="section.title" :value="section.title" class="mt-4 outline-none">
<section class="rounded-xl border bg-card shadow-sm">
<div class="border-b px-5 py-4"><h2 class="font-semibold">{{ section.title }}</h2><p class="mt-1 text-sm text-muted-foreground">{{ section.description }}</p></div>
<div class="grid gap-x-6 px-5 sm:grid-cols-2">
<label v-for="field in section.fields" :key="field.path" class="config-field" :class="field.type === 'boolean' ? 'flex-row items-center' : 'flex-col'">
<span class="min-w-0 flex-1"><b>{{ field.label }}</b><small>{{ field.description }}</small></span>
<input v-if="field.type === 'boolean'" class="size-4 accent-foreground" type="checkbox" :checked="Boolean(fieldValue(field.path))" @change="updateField(field, $event)" />
<select v-else-if="field.type === 'select'" class="config-input" :value="String(fieldValue(field.path))" @change="updateField(field, $event)"><option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</option></select>
<input v-else class="config-input" :type="field.type" :value="fieldValue(field.path) ?? ''" :min="field.type === 'number' ? 0 : undefined" step="1" @input="updateField(field, $event)" />
</label>
</div>
</section>
</TabsContent>
</TabsRoot>
</form>
</main>
</template>
+52 -15
View File
@@ -9,21 +9,24 @@ import type { EChartsOption } from 'echarts'
import VChart from 'vue-echarts'
import { AppSelect } from '@/components/ui/select'
import { getCurrentDiagnostics, getDiagnosticHistory } from '@/lib/api'
import { getCurrentDiagnostics, getDiagnosticHistory, getStats } from '@/lib/api'
import { formatBytes, formatDate } from '@/lib/format'
import type { CurrentDiagnostics, DiagnosticHistory, DiagnosticSample } from '@/types/api'
import type { CurrentDiagnostics, DiagnosticHistory, DiagnosticSample, ServiceStats } from '@/types/api'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
const rangeSecs = ref(3_600)
const current = ref<CurrentDiagnostics | null>(null)
const history = ref<DiagnosticHistory | null>(null)
const stats = ref<ServiceStats | null>(null)
const loading = ref(true)
const error = ref('')
let currentTimer: number | null = null
let historyTimer: number | null = null
let statsTimer: number | null = null
let currentRequest = false
let historyRequest = false
let statsRequest = false
const rangeOptions = [
{ value: '3600', label: '最近 1 小时' },
@@ -121,15 +124,35 @@ function changeRange(value: string) {
rangeSecs.value = Number(value)
}
function diskStateLabel(state: ServiceStats['disk_state']): string {
if (state === 'normal') return '正常'
if (state === 'draining') return '正在排空'
return '只读保护'
}
async function loadStats() {
if (statsRequest) return
statsRequest = true
try {
stats.value = await getStats()
} catch {
// 保留最近一次成功快照避免短暂网络错误造成页面闪烁
} finally {
statsRequest = false
}
}
watch(rangeSecs, () => void loadHistory())
onMounted(() => {
void Promise.all([loadCurrent(), loadHistory()])
void Promise.all([loadCurrent(), loadHistory(), loadStats()])
statsTimer = window.setInterval(() => void loadStats(), 1_000)
currentTimer = window.setInterval(() => void loadCurrent(), 2_000)
historyTimer = window.setInterval(() => void loadHistory(), 10_000)
})
onBeforeUnmount(() => {
if (statsTimer !== null) window.clearInterval(statsTimer)
if (currentTimer !== null) window.clearInterval(currentTimer)
if (historyTimer !== null) window.clearInterval(historyTimer)
})
@@ -137,9 +160,9 @@ onBeforeUnmount(() => {
<template>
<main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 sm:py-10">
<div class="mb-6 flex flex-wrap items-center gap-3">
<div><h1 class="text-xl font-semibold tracking-tight">运行诊断</h1><p class="mt-1 text-sm text-muted-foreground">资源占用存储压力和采集吞吐</p></div>
<AppSelect class="ml-auto" :model-value="String(rangeSecs)" :options="rangeOptions" label="诊断时间范围" @update:model-value="changeRange" />
<div class="mb-6 flex items-center justify-between gap-3">
<h1 class="text-xl font-semibold tracking-tight">运行诊断</h1>
<AppSelect :model-value="String(rangeSecs)" :options="rangeOptions" label="诊断时间范围" @update:model-value="changeRange" />
</div>
<div v-if="current?.status.last_error || current?.status.write_failures" class="mb-4 flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm"><AlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-500" /><div><p class="font-medium">诊断数据写入异常</p><p class="mt-1 text-muted-foreground">{{ current.status.last_error ?? `${current.status.write_failures} 次写入失败` }}</p></div></div>
@@ -147,24 +170,38 @@ onBeforeUnmount(() => {
<div v-if="loading && !sample" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在读取诊断数据</div>
<template v-else-if="sample">
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<section v-if="stats">
<h2 class="mb-3 text-sm font-semibold">服务运行状态</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<article class="diagnostic-card"><HardDrive /><span>磁盘状态</span><b class="flex items-center gap-2"><i class="size-1.5 rounded-full" :class="stats.disk_state === 'normal' ? 'bg-emerald-500' : 'bg-amber-500'" />{{ diskStateLabel(stats.disk_state) }}</b></article>
<article class="diagnostic-card"><HardDrive /><span>磁盘剩余</span><b>{{ stats.disk_available_bytes === null ? '未知' : formatBytes(stats.disk_available_bytes) }}</b></article>
<article class="diagnostic-card"><Network /><span>DHT 节点</span><b>{{ stats.nodes.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Gauge /><span>Metadata 成功</span><b>{{ stats.metadata_ok.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Gauge /><span>Metadata 下载中</span><b>{{ stats.metadata_in_flight.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Database /><span>新收录</span><b>{{ stats.persistence_inserted.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Database /><span>已过滤</span><b>{{ stats.metadata_filtered.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Database /><span>已索引内容</span><b>{{ stats.indexed_documents.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Gauge /><span>验证成功</span><b>{{ stats.verification_succeeded.toLocaleString() }}</b></article>
</div>
</section>
<section class="mt-6">
<h2 class="mb-3 text-sm font-semibold">资源状态</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<article class="diagnostic-card"><MemoryStick /><span>私有内存</span><b>{{ sample.process.private_memory_bytes === null ? '未知' : formatBytes(sample.process.private_memory_bytes) }}</b></article>
<article class="diagnostic-card"><Cpu /><span>CPU 时间</span><b>{{ sample.process.cpu_time_millis === null ? '未知' : `${(sample.process.cpu_time_millis / 1000).toFixed(1)}` }}</b></article>
<article class="diagnostic-card"><Network /><span>DHT 节点</span><b>{{ sample.runtime.nodes.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Gauge /><span>Metadata 下载中</span><b>{{ sample.runtime.metadata_in_flight.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Database /><span>RocksDB 缓存</span><b>{{ sample.storage.block_cache_bytes === null ? '未知' : formatBytes(sample.storage.block_cache_bytes) }}</b></article>
<article class="diagnostic-card"><HardDrive /><span> Compaction</span><b>{{ sample.storage.pending_compaction_bytes === null ? '未知' : formatBytes(sample.storage.pending_compaction_bytes) }}</b></article>
<article class="diagnostic-card"><Database /><span>索引文档</span><b>{{ sample.search.documents.toLocaleString() }}</b></article>
<article class="diagnostic-card"><Gauge /><span>最近索引提交</span><b>{{ sample.search.last_commit_duration_millis }} ms</b></article>
<article class="diagnostic-card"><Network /><span>HTTP 并发</span><b>{{ sample.http.active_requests }}</b></article>
<article class="diagnostic-card"><Gauge /><span>HTTP P95</span><b>{{ sample.http.latency_p95_millis === null ? '暂无' : `${sample.http.latency_p95_millis} ms` }}</b></article>
</div>
</section>
<section class="mt-5 grid gap-4 lg:grid-cols-2">
<article class="chart-card"><div><h2>进程内存</h2><p>{{ sample.process.thread_count ?? '未知' }} 个线程 · {{ sample.process.handle_count ?? '未知' }} 个句柄</p></div><VChart class="h-72" :option="memoryChart" autoresize /></article>
<article class="chart-card"><div><h2>RocksDB 内存与压缩</h2><p>SST {{ sample.storage.live_sst_bytes === null ? '未知' : formatBytes(sample.storage.live_sst_bytes) }}</p></div><VChart class="h-72" :option="storageChart" autoresize /></article>
<article class="chart-card lg:col-span-2"><div><h2>网络与 Metadata 吞吐</h2><p>{{ history?.samples.length ?? 0 }} 个采样点 · 最近保存 {{ formatDate(current?.status.last_persisted_at ?? null) }}</p></div><VChart class="h-72" :option="throughputChart" autoresize /></article>
<article class="chart-card lg:col-span-2"><div><h2>HTTP 请求与错误</h2><p>累计 {{ sample.http.requests.toLocaleString() }} 个请求 · {{ sample.http.server_errors.toLocaleString() }} 个服务端错误</p></div><VChart class="h-72" :option="httpChart" autoresize /></article>
<article class="chart-card"><div><h2>进程内存</h2><p>{{ sample.process.thread_count ?? '未知' }} 个线程 · {{ sample.process.handle_count ?? '未知' }} 个句柄</p></div><VChart class="diagnostic-chart" :option="memoryChart" autoresize /></article>
<article class="chart-card"><div><h2>RocksDB 内存与压缩</h2><p>SST {{ sample.storage.live_sst_bytes === null ? '未知' : formatBytes(sample.storage.live_sst_bytes) }}</p></div><VChart class="diagnostic-chart" :option="storageChart" autoresize /></article>
<article class="chart-card lg:col-span-2"><div><h2>网络与 Metadata 吞吐</h2><p>{{ history?.samples.length ?? 0 }} 个采样点 · 最近保存 {{ formatDate(current?.status.last_persisted_at ?? null) }}</p></div><VChart class="diagnostic-chart" :option="throughputChart" autoresize /></article>
<article class="chart-card lg:col-span-2"><div><h2>HTTP 请求与错误</h2><p>累计 {{ sample.http.requests.toLocaleString() }} 个请求 · {{ sample.http.server_errors.toLocaleString() }} 个服务端错误</p></div><VChart class="diagnostic-chart" :option="httpChart" autoresize /></article>
</section>
</template>
</main>
+9 -9
View File
@@ -140,15 +140,6 @@
.result-meta svg {
@apply size-3.5;
}
.status-cell {
@apply flex flex-col gap-1 rounded-lg bg-muted/60 p-3;
}
.status-cell span {
@apply text-muted-foreground;
}
.status-cell b {
@apply text-sm font-semibold;
}
.pagination-button {
@apply flex size-8 items-center justify-center rounded-md text-xs font-medium transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40;
}
@@ -197,3 +188,12 @@
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-enter-active, .slide-leave-active { transition: transform 220ms ease; }
.slide-enter-from, .slide-leave-to { transform: translateX(100%); }
/* 覆盖 vue-echarts 未分层的百分比高度并切断父子容器的尺寸反馈 */
.diagnostic-chart {
width: 100%;
height: 18rem;
min-height: 18rem;
max-height: 18rem;
overflow: hidden;
}