新增: - utils/bugCollect.ts - levelTag/statusTag/statusLabel/formatTime/platformTagType 重构: - BugCollectOverview.vue - 使用公共 levelTag - BugCollectIssues.vue - 使用公共辅助函数 - BugCollectEvents.vue - 使用公共 formatTime - BugCollectSourcemaps.vue - 使用公共辅助函数 - BugCollectRankFreq.vue - 使用公共辅助函数 - BugCollectRankRisk.vue - 使用公共辅助函数 - BugCollectIssueDetail.vue - 使用公共辅助函数 Co-Authored-By: Claude <noreply@anthropic.com>
369 行
13 KiB
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>
|