XuqmGroup-Web/tenant-platform/src/views/bug-collect/BugCollectOverview.vue

369 行
13 KiB
Vue

<template>
<div>
<h2 style="margin-bottom: 24px">Bug 概览</h2>
<!-- 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>
<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>
</div>
<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>
<template v-else>
<!-- 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">
<el-statistic title="未解决数" :value="stats.statusDistribution.find(s => s.name === 'open')?.value ?? 0">
<template #prefix><el-icon><WarningFilled /></el-icon></template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<!-- 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>
<!-- 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 }">
<el-tag size="small" :type="levelTag(row.type)">{{ row.type }}</el-tag>
</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>
</template>
<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>
</div>
</template>
<script setup lang="ts">
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'
import { useBugCollectApp } from '@/composables/useBugCollectApp'
import { levelTag } from '@/utils/bugCollect'
import * as echarts from 'echarts'
const { apps, loadingApps, appKey, setApp, gateStatus, applyDialogVisible, applyReason, applyLoading, submitActivation } = useBugCollectApp()
const statsDays = ref(7)
const EMPTY_OVERVIEW: BugCollectOverview = {
totalIssues: 0,
todayNewIssues: 0,
affectedUsers: 0,
crashRate: 0,
crashRateTrend: [],
topIssues: [],
}
const EMPTY_STATS: BugCollectStatistics = {
levelDistribution: [],
statusDistribution: [],
platformDistribution: [],
versionDistribution: [],
dailyTrend: [],
}
const overview = ref<BugCollectOverview>({ ...EMPTY_OVERVIEW })
const stats = ref<BugCollectStatistics>({ ...EMPTY_STATS })
// 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)
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 getDaysAgo(days: number): string {
const d = new Date()
d.setDate(d.getDate() - days)
return d.toISOString().split('T')[0]
}
async function loadData() {
if (!appKey.value || gateStatus.value !== 'enabled') return
const from = getDaysAgo(statsDays.value)
const to = new Date().toISOString().split('T')[0]
try {
const [overviewRes, statsRes] = await Promise.all([
bugCollectApi.overview(appKey.value),
bugCollectApi.statistics(appKey.value, { from, to }),
])
const d = overviewRes.data.data
overview.value = {
totalIssues: d?.totalIssues ?? 0,
todayNewIssues: d?.todayNewIssues ?? 0,
affectedUsers: d?.affectedUsers ?? 0,
crashRate: d?.crashRate ?? 0,
crashRateTrend: d?.crashRateTrend ?? [],
topIssues: d?.topIssues ?? [],
}
stats.value = statsRes.data.data ?? EMPTY_STATS
await nextTick()
renderCharts()
} catch {
overview.value = { ...EMPTY_OVERVIEW }
stats.value = { ...EMPTY_STATS }
}
}
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()
}
watch(gateStatus, (s) => { if (s === 'enabled') loadData() })
onMounted(() => {
loadData()
window.addEventListener('resize', handleChartResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleChartResize)
trendChart?.dispose()
levelChart?.dispose()
statusChart?.dispose()
platformChart?.dispose()
versionChart?.dispose()
})
</script>
<style scoped>
.app-selector-bar { display:flex; align-items:center; gap:12px; margin-bottom:20px; }
.selector-label { font-size:14px; color:#606266; }
.toolbar-space-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stats-card {
min-height: 320px;
}
.stats-card-wide {
grid-column: 1 / -1;
}
.chart-container {
width: 100%;
height: 260px;
}
</style>