2026-06-16 18:30:01 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 style="margin-bottom: 24px">Bug 概览</h2>
|
|
|
|
|
|
|
2026-06-17 05:00:51 +08:00
|
|
|
|
<!-- App selector bar -->
|
|
|
|
|
|
<div class="app-selector-bar">
|
|
|
|
|
|
<span class="selector-label">选择应用</span>
|
|
|
|
|
|
<el-select
|
|
|
|
|
|
:model-value="appKey"
|
|
|
|
|
|
placeholder="请选择应用"
|
|
|
|
|
|
style="width:220px"
|
|
|
|
|
|
:loading="loadingApps"
|
|
|
|
|
|
@change="setApp"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-option v-for="a in apps" :key="a.appKey" :label="a.name" :value="a.appKey" />
|
|
|
|
|
|
</el-select>
|
2026-06-19 01:23:32 +08:00
|
|
|
|
<el-select v-model="statsDays" style="width:120px;margin-left:12px" @change="loadData">
|
|
|
|
|
|
<el-option :value="7" label="近 7 天" />
|
|
|
|
|
|
<el-option :value="14" label="近 14 天" />
|
|
|
|
|
|
<el-option :value="30" label="近 30 天" />
|
|
|
|
|
|
</el-select>
|
2026-06-17 05:00:51 +08:00
|
|
|
|
</div>
|
2026-06-17 05:25:54 +08:00
|
|
|
|
<el-empty v-if="gateStatus === 'no-app'" description="请选择一个应用" style="margin-top:80px" />
|
|
|
|
|
|
<div v-else-if="gateStatus === 'loading'" v-loading="true" style="min-height:200px" />
|
|
|
|
|
|
<div v-else-if="gateStatus === 'not-enabled'" style="margin-top:60px;text-align:center">
|
|
|
|
|
|
<el-empty description="当前应用未开通崩溃收集服务">
|
|
|
|
|
|
<el-button type="primary" @click="applyDialogVisible = true">申请开通</el-button>
|
|
|
|
|
|
</el-empty>
|
|
|
|
|
|
</div>
|
2026-06-17 05:00:51 +08:00
|
|
|
|
<template v-else>
|
|
|
|
|
|
|
2026-06-16 18:30:01 +08:00
|
|
|
|
<!-- Stats Cards -->
|
|
|
|
|
|
<el-row :gutter="16">
|
|
|
|
|
|
<el-col :span="6">
|
|
|
|
|
|
<el-card shadow="hover">
|
|
|
|
|
|
<el-statistic title="总 Issue 数" :value="overview.totalIssues">
|
|
|
|
|
|
<template #prefix><el-icon><Warning /></el-icon></template>
|
|
|
|
|
|
</el-statistic>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
<el-col :span="6">
|
|
|
|
|
|
<el-card shadow="hover">
|
|
|
|
|
|
<el-statistic title="今日新增" :value="overview.todayNewIssues">
|
|
|
|
|
|
<template #prefix><el-icon><Plus /></el-icon></template>
|
|
|
|
|
|
</el-statistic>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
<el-col :span="6">
|
|
|
|
|
|
<el-card shadow="hover">
|
|
|
|
|
|
<el-statistic title="影响用户数" :value="overview.affectedUsers">
|
|
|
|
|
|
<template #prefix><el-icon><User /></el-icon></template>
|
|
|
|
|
|
</el-statistic>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
<el-col :span="6">
|
|
|
|
|
|
<el-card shadow="hover">
|
2026-06-19 01:23:32 +08:00
|
|
|
|
<el-statistic title="未解决数" :value="stats.statusDistribution.find(s => s.name === 'open')?.value ?? 0">
|
|
|
|
|
|
<template #prefix><el-icon><WarningFilled /></el-icon></template>
|
|
|
|
|
|
</el-statistic>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</el-card>
|
|
|
|
|
|
</el-col>
|
|
|
|
|
|
</el-row>
|
|
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
<!-- Charts Grid -->
|
|
|
|
|
|
<div class="stats-grid">
|
|
|
|
|
|
<!-- Daily Trend (stacked bar) -->
|
|
|
|
|
|
<el-card shadow="never" class="stats-card stats-card-wide">
|
|
|
|
|
|
<template #header>每日 Issue 趋势(按级别堆叠)</template>
|
|
|
|
|
|
<div ref="trendChartRef" class="chart-container"></div>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Level Distribution -->
|
|
|
|
|
|
<el-card shadow="never" class="stats-card">
|
|
|
|
|
|
<template #header>级别分布</template>
|
|
|
|
|
|
<div ref="levelChartRef" class="chart-container"></div>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Status Distribution -->
|
|
|
|
|
|
<el-card shadow="never" class="stats-card">
|
|
|
|
|
|
<template #header>状态分布</template>
|
|
|
|
|
|
<div ref="statusChartRef" class="chart-container"></div>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Platform Distribution -->
|
|
|
|
|
|
<el-card shadow="never" class="stats-card">
|
|
|
|
|
|
<template #header>平台分布</template>
|
|
|
|
|
|
<div ref="platformChartRef" class="chart-container"></div>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Version Distribution -->
|
|
|
|
|
|
<el-card shadow="never" class="stats-card">
|
|
|
|
|
|
<template #header>版本分布 Top 10</template>
|
|
|
|
|
|
<div ref="versionChartRef" class="chart-container"></div>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</div>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Top 5 Issues -->
|
|
|
|
|
|
<el-card style="margin-top: 16px">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="toolbar toolbar-space-between">
|
|
|
|
|
|
<span>高频错误 Top 5</span>
|
|
|
|
|
|
<el-button link type="primary" @click="$router.push('/bugcollect/rank/freq')">查看全部</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<el-table :data="overview.topIssues" border stripe>
|
|
|
|
|
|
<el-table-column prop="title" label="错误标题" min-width="300" show-overflow-tooltip />
|
|
|
|
|
|
<el-table-column prop="type" label="类型" width="120">
|
|
|
|
|
|
<template #default="{ row }">
|
2026-06-19 01:23:32 +08:00
|
|
|
|
<el-tag size="small" :type="levelTag(row.type)">{{ row.type }}</el-tag>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
<el-table-column prop="count" label="次数" width="100" sortable />
|
|
|
|
|
|
<el-table-column label="操作" width="100" align="center">
|
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
|
<el-button link type="primary" size="small" @click="$router.push(`/bugcollect/issues/${row.id}`)">详情</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
</el-card>
|
2026-06-17 05:00:51 +08:00
|
|
|
|
</template>
|
2026-06-17 05:25:54 +08:00
|
|
|
|
<el-dialog v-model="applyDialogVisible" title="申请开通崩溃收集" width="400px" :close-on-click-modal="false">
|
|
|
|
|
|
<el-input v-model="applyReason" type="textarea" placeholder="请说明申请原因(选填)" :rows="3" />
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="applyDialogVisible = false">取消</el-button>
|
|
|
|
|
|
<el-button type="primary" :loading="applyLoading" @click="submitActivation">提交申请</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-06-19 01:23:32 +08:00
|
|
|
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
|
|
|
|
|
import { bugCollectApi, type BugCollectOverview, type BugCollectStatistics } from '@/api/bugcollect'
|
|
|
|
|
|
import { Warning, Plus, User, WarningFilled } from '@element-plus/icons-vue'
|
2026-06-17 05:00:51 +08:00
|
|
|
|
import { useBugCollectApp } from '@/composables/useBugCollectApp'
|
2026-06-19 01:23:32 +08:00
|
|
|
|
import * as echarts from 'echarts'
|
2026-06-17 05:00:51 +08:00
|
|
|
|
|
2026-06-17 05:25:54 +08:00
|
|
|
|
const { apps, loadingApps, appKey, setApp, gateStatus, applyDialogVisible, applyReason, applyLoading, submitActivation } = useBugCollectApp()
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
const statsDays = ref(7)
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_OVERVIEW: BugCollectOverview = {
|
2026-06-16 18:30:01 +08:00
|
|
|
|
totalIssues: 0,
|
|
|
|
|
|
todayNewIssues: 0,
|
|
|
|
|
|
affectedUsers: 0,
|
|
|
|
|
|
crashRate: 0,
|
|
|
|
|
|
crashRateTrend: [],
|
|
|
|
|
|
topIssues: [],
|
2026-06-17 10:07:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
const EMPTY_STATS: BugCollectStatistics = {
|
|
|
|
|
|
levelDistribution: [],
|
|
|
|
|
|
statusDistribution: [],
|
|
|
|
|
|
platformDistribution: [],
|
|
|
|
|
|
versionDistribution: [],
|
|
|
|
|
|
dailyTrend: [],
|
|
|
|
|
|
}
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
const overview = ref<BugCollectOverview>({ ...EMPTY_OVERVIEW })
|
|
|
|
|
|
const stats = ref<BugCollectStatistics>({ ...EMPTY_STATS })
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
// Chart refs
|
|
|
|
|
|
const trendChartRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const levelChartRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const statusChartRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const platformChartRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
const versionChartRef = ref<HTMLElement | null>(null)
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
let trendChart: echarts.ECharts | null = null
|
|
|
|
|
|
let levelChart: echarts.ECharts | null = null
|
|
|
|
|
|
let statusChart: echarts.ECharts | null = null
|
|
|
|
|
|
let platformChart: echarts.ECharts | null = null
|
|
|
|
|
|
let versionChart: echarts.ECharts | null = null
|
|
|
|
|
|
|
|
|
|
|
|
function levelTag(level: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
|
|
|
|
|
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
|
|
|
|
|
fatal: 'danger',
|
|
|
|
|
|
error: 'warning',
|
|
|
|
|
|
warning: '',
|
|
|
|
|
|
info: 'info',
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
2026-06-19 01:23:32 +08:00
|
|
|
|
return map[level?.toLowerCase()] ?? ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDaysAgo(days: number): string {
|
|
|
|
|
|
const d = new Date()
|
|
|
|
|
|
d.setDate(d.getDate() - days)
|
|
|
|
|
|
return d.toISOString().split('T')[0]
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 10:07:32 +08:00
|
|
|
|
async function loadData() {
|
|
|
|
|
|
if (!appKey.value || gateStatus.value !== 'enabled') return
|
2026-06-19 01:23:32 +08:00
|
|
|
|
const from = getDaysAgo(statsDays.value)
|
|
|
|
|
|
const to = new Date().toISOString().split('T')[0]
|
|
|
|
|
|
|
2026-06-16 18:30:01 +08:00
|
|
|
|
try {
|
2026-06-19 01:23:32 +08:00
|
|
|
|
const [overviewRes, statsRes] = await Promise.all([
|
|
|
|
|
|
bugCollectApi.overview(appKey.value),
|
|
|
|
|
|
bugCollectApi.statistics(appKey.value, { from, to }),
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
const d = overviewRes.data.data
|
2026-06-17 10:07:32 +08:00
|
|
|
|
overview.value = {
|
2026-06-17 10:10:20 +08:00
|
|
|
|
totalIssues: d?.totalIssues ?? 0,
|
|
|
|
|
|
todayNewIssues: d?.todayNewIssues ?? 0,
|
|
|
|
|
|
affectedUsers: d?.affectedUsers ?? 0,
|
|
|
|
|
|
crashRate: d?.crashRate ?? 0,
|
|
|
|
|
|
crashRateTrend: d?.crashRateTrend ?? [],
|
|
|
|
|
|
topIssues: d?.topIssues ?? [],
|
2026-06-17 10:07:32 +08:00
|
|
|
|
}
|
2026-06-19 01:23:32 +08:00
|
|
|
|
|
|
|
|
|
|
stats.value = statsRes.data.data ?? EMPTY_STATS
|
|
|
|
|
|
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
renderCharts()
|
2026-06-17 10:07:32 +08:00
|
|
|
|
} catch {
|
2026-06-19 01:23:32 +08:00
|
|
|
|
overview.value = { ...EMPTY_OVERVIEW }
|
|
|
|
|
|
stats.value = { ...EMPTY_STATS }
|
2026-06-17 10:07:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
const LEVEL_COLORS: Record<string, string> = {
|
|
|
|
|
|
fatal: '#F56C6C',
|
|
|
|
|
|
error: '#E6A23C',
|
|
|
|
|
|
warning: '#409EFF',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
|
|
|
|
open: '#F56C6C',
|
|
|
|
|
|
resolved: '#67C23A',
|
|
|
|
|
|
ignored: '#909399',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCharts() {
|
|
|
|
|
|
// Daily trend stacked bar
|
|
|
|
|
|
if (trendChartRef.value) {
|
|
|
|
|
|
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
|
|
|
|
|
|
const trend = stats.value.dailyTrend
|
|
|
|
|
|
trendChart.setOption({
|
|
|
|
|
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
|
|
|
legend: { data: ['fatal', 'error', 'warning'] },
|
|
|
|
|
|
grid: { left: 50, right: 20, top: 40, bottom: 30 },
|
|
|
|
|
|
xAxis: { type: 'category', data: trend.map(d => d.date) },
|
|
|
|
|
|
yAxis: { type: 'value', minInterval: 1 },
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{ name: 'fatal', type: 'bar', stack: 'total', data: trend.map(d => d.fatal), itemStyle: { color: LEVEL_COLORS.fatal } },
|
|
|
|
|
|
{ name: 'error', type: 'bar', stack: 'total', data: trend.map(d => d.error), itemStyle: { color: LEVEL_COLORS.error } },
|
|
|
|
|
|
{ name: 'warning', type: 'bar', stack: 'total', data: trend.map(d => d.warning), itemStyle: { color: LEVEL_COLORS.warning } },
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Level distribution pie
|
|
|
|
|
|
if (levelChartRef.value) {
|
|
|
|
|
|
if (!levelChart) levelChart = echarts.init(levelChartRef.value)
|
|
|
|
|
|
levelChart.setOption({
|
|
|
|
|
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
|
|
|
|
|
legend: { orient: 'vertical', right: 10, top: 'center' },
|
|
|
|
|
|
series: [{
|
|
|
|
|
|
type: 'pie',
|
|
|
|
|
|
radius: ['40%', '70%'],
|
|
|
|
|
|
avoidLabelOverlap: false,
|
|
|
|
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
|
|
|
|
|
data: stats.value.levelDistribution.map(d => ({
|
|
|
|
|
|
name: d.name,
|
|
|
|
|
|
value: d.value,
|
|
|
|
|
|
itemStyle: { color: LEVEL_COLORS[d.name] },
|
|
|
|
|
|
})),
|
|
|
|
|
|
}],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Status distribution pie
|
|
|
|
|
|
if (statusChartRef.value) {
|
|
|
|
|
|
if (!statusChart) statusChart = echarts.init(statusChartRef.value)
|
|
|
|
|
|
const statusLabels: Record<string, string> = { open: '未解决', resolved: '已解决', ignored: '已忽略' }
|
|
|
|
|
|
statusChart.setOption({
|
|
|
|
|
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
|
|
|
|
|
legend: { orient: 'vertical', right: 10, top: 'center' },
|
|
|
|
|
|
series: [{
|
|
|
|
|
|
type: 'pie',
|
|
|
|
|
|
radius: ['40%', '70%'],
|
|
|
|
|
|
avoidLabelOverlap: false,
|
|
|
|
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
|
|
|
|
|
data: stats.value.statusDistribution.map(d => ({
|
|
|
|
|
|
name: statusLabels[d.name] ?? d.name,
|
|
|
|
|
|
value: d.value,
|
|
|
|
|
|
itemStyle: { color: STATUS_COLORS[d.name] },
|
|
|
|
|
|
})),
|
|
|
|
|
|
}],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Platform distribution pie
|
|
|
|
|
|
if (platformChartRef.value) {
|
|
|
|
|
|
if (!platformChart) platformChart = echarts.init(platformChartRef.value)
|
|
|
|
|
|
platformChart.setOption({
|
|
|
|
|
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
|
|
|
|
|
legend: { orient: 'vertical', right: 10, top: 'center' },
|
|
|
|
|
|
series: [{
|
|
|
|
|
|
type: 'pie',
|
|
|
|
|
|
radius: ['40%', '70%'],
|
|
|
|
|
|
avoidLabelOverlap: false,
|
|
|
|
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
|
|
|
|
|
data: stats.value.platformDistribution.map(d => ({ name: d.name, value: d.value })),
|
|
|
|
|
|
}],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Version distribution horizontal bar
|
|
|
|
|
|
if (versionChartRef.value) {
|
|
|
|
|
|
if (!versionChart) versionChart = echarts.init(versionChartRef.value)
|
|
|
|
|
|
const versionData = [...stats.value.versionDistribution].reverse()
|
|
|
|
|
|
versionChart.setOption({
|
|
|
|
|
|
tooltip: { trigger: 'axis' },
|
|
|
|
|
|
grid: { left: 80, right: 20, top: 10, bottom: 30 },
|
|
|
|
|
|
xAxis: { type: 'value', minInterval: 1 },
|
|
|
|
|
|
yAxis: { type: 'category', data: versionData.map(d => d.name), axisLabel: { width: 60, overflow: 'truncate' } },
|
|
|
|
|
|
series: [{ type: 'bar', data: versionData.map(d => d.value) }],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleChartResize() {
|
|
|
|
|
|
trendChart?.resize()
|
|
|
|
|
|
levelChart?.resize()
|
|
|
|
|
|
statusChart?.resize()
|
|
|
|
|
|
platformChart?.resize()
|
|
|
|
|
|
versionChart?.resize()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 10:07:32 +08:00
|
|
|
|
watch(gateStatus, (s) => { if (s === 'enabled') loadData() })
|
2026-06-19 01:23:32 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
loadData()
|
|
|
|
|
|
window.addEventListener('resize', handleChartResize)
|
|
|
|
|
|
})
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.removeEventListener('resize', handleChartResize)
|
|
|
|
|
|
trendChart?.dispose()
|
|
|
|
|
|
levelChart?.dispose()
|
|
|
|
|
|
statusChart?.dispose()
|
|
|
|
|
|
platformChart?.dispose()
|
|
|
|
|
|
versionChart?.dispose()
|
|
|
|
|
|
})
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-06-17 05:00:51 +08:00
|
|
|
|
.app-selector-bar { display:flex; align-items:center; gap:12px; margin-bottom:20px; }
|
|
|
|
|
|
.selector-label { font-size:14px; color:#606266; }
|
2026-06-19 01:23:32 +08:00
|
|
|
|
.toolbar-space-between {
|
2026-06-16 18:30:01 +08:00
|
|
|
|
display: flex;
|
2026-06-19 01:23:32 +08:00
|
|
|
|
justify-content: space-between;
|
2026-06-16 18:30:01 +08:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
2026-06-19 01:23:32 +08:00
|
|
|
|
.stats-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
margin-top: 16px;
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
2026-06-19 01:23:32 +08:00
|
|
|
|
.stats-card {
|
|
|
|
|
|
min-height: 320px;
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
2026-06-19 01:23:32 +08:00
|
|
|
|
.stats-card-wide {
|
|
|
|
|
|
grid-column: 1 / -1;
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
2026-06-19 01:23:32 +08:00
|
|
|
|
.chart-container {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 260px;
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|