feat: update 统计图表 + bugcollect ECharts 图表增强 + 修复字段映射
- update: 新增版本分布/设备分布/厂商分布/日趋势统计图表(ECharts) - update: 添加 echarts 依赖 - bugcollect: 概览页新增 5 张 ECharts 图表(级别/状态/平台/版本分布 + 每日趋势) - bugcollect: 高频/高危排行页添加横向柱状图 - bugcollect: Issue 详情页添加趋势双轴折线图 - bugcollect: 漏斗页替换为 ECharts 漏斗图 - bugcollect: 修复 type→level、isResolved→status 字段引用 - bugcollect: 新增 statistics 和 issueTrend API Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
父节点
bb52b8e0b7
当前提交
1dc188b2be
@ -12,6 +12,7 @@
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@xuqm/vue3-sdk": "^0.2.3",
|
||||
"axios": "^1.7.9",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
@ -101,6 +101,14 @@ export interface BugCollectWebhookRequest {
|
||||
secret?: string
|
||||
}
|
||||
|
||||
export interface BugCollectStatistics {
|
||||
levelDistribution: { name: string; value: number }[]
|
||||
statusDistribution: { name: string; value: number }[]
|
||||
platformDistribution: { name: string; value: number }[]
|
||||
versionDistribution: { name: string; value: number }[]
|
||||
dailyTrend: { date: string; fatal: number; error: number; warning: number; total: number }[]
|
||||
}
|
||||
|
||||
// Matches backend PageResult<T>
|
||||
export interface BugCollectPageResult<T> {
|
||||
items: T[]
|
||||
@ -116,6 +124,10 @@ export const bugCollectApi = {
|
||||
overview: (appKey: string) =>
|
||||
client.get<{ data: BugCollectOverview }>('/bugcollect/v1/overview', { params: { appKey } }),
|
||||
|
||||
// Statistics
|
||||
statistics: (appKey: string, params?: { from?: string; to?: string }) =>
|
||||
client.get<{ data: BugCollectStatistics }>('/bugcollect/v1/statistics', { params: { appKey, ...params } }),
|
||||
|
||||
// Issues — page is 1-based (backend convention); startDate/endDate mapped to from/to
|
||||
issues(appKey: string, params: {
|
||||
level?: string
|
||||
@ -139,6 +151,13 @@ export const bugCollectApi = {
|
||||
return client.get<{ data: BugCollectIssueDetail }>(`/bugcollect/v1/issues/${id}`)
|
||||
},
|
||||
|
||||
issueTrend(id: string | number, params?: { from?: string; to?: string }) {
|
||||
return client.get<{ data: { date: string; count: number; affectedUsers: number }[] }>(
|
||||
`/bugcollect/v1/issues/${id}/trend`,
|
||||
{ params },
|
||||
)
|
||||
},
|
||||
|
||||
resolveIssue(id: string | number) {
|
||||
return client.put<{ data: void }>(`/bugcollect/v1/issues/${id}/resolve`)
|
||||
},
|
||||
|
||||
@ -446,4 +446,30 @@ export const updateAdminApi = {
|
||||
removeMembersFromTag(appKey: string, tagName: string, userIds: string[]) {
|
||||
return updateClient.delete(`/api/v1/updates/gray/tags/${tagName}/members`, { data: { appKey, userIds } })
|
||||
},
|
||||
|
||||
// ── Statistics ─────────────────────────────────────────────────────────
|
||||
|
||||
getVersionDistribution(appKey: string, platform: string, days?: number) {
|
||||
return updateClient.get<{ data: { versionCode: number; versionName: string; deviceCount: number }[] }>(
|
||||
'/api/v1/updates/stats/version-distribution',
|
||||
{ params: { appKey, platform, days: days ?? 30 } },
|
||||
)
|
||||
},
|
||||
|
||||
getDeviceDistribution(appKey: string, platform: string, days?: number) {
|
||||
return updateClient.get<{
|
||||
data: {
|
||||
models: { model: string; deviceCount: number }[]
|
||||
osVersions: { osVersion: string; deviceCount: number }[]
|
||||
vendors: { vendor: string; deviceCount: number }[]
|
||||
}
|
||||
}>('/api/v1/updates/stats/device-distribution', { params: { appKey, platform, days: days ?? 30 } })
|
||||
},
|
||||
|
||||
getDailyTrend(appKey: string, platform: string, days?: number) {
|
||||
return updateClient.get<{ data: { day: string; deviceCount: number }[] }>(
|
||||
'/api/v1/updates/stats/daily-trend',
|
||||
{ params: { appKey, platform, days: days ?? 7 } },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@ -41,20 +41,24 @@
|
||||
|
||||
<el-card v-if="funnelData.length">
|
||||
<template #header>漏斗结果</template>
|
||||
<div class="funnel-result">
|
||||
<div v-for="(step, idx) in funnelData" :key="idx" class="funnel-item">
|
||||
<div class="funnel-step-info">
|
||||
<div class="funnel-step-name">{{ step.eventName }}</div>
|
||||
<div class="funnel-step-count">{{ step.count }} 人</div>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="step.conversionRate"
|
||||
:stroke-width="24"
|
||||
:color="funnelColor(idx)"
|
||||
:format="(p: number) => p.toFixed(1) + '%'"
|
||||
/>
|
||||
<div v-if="idx < funnelData.length - 1" class="funnel-drop">
|
||||
转化 {{ step.conversionRate.toFixed(1) }}% ↓
|
||||
<div class="funnel-charts">
|
||||
<div ref="funnelChartRef" class="funnel-chart"></div>
|
||||
<div class="funnel-detail">
|
||||
<div v-for="(step, idx) in funnelData" :key="idx" class="funnel-item">
|
||||
<div class="funnel-step-info">
|
||||
<span class="funnel-step-index">{{ idx + 1 }}</span>
|
||||
<span class="funnel-step-name">{{ step.eventName }}</span>
|
||||
<span class="funnel-step-count">{{ step.count }} 人</span>
|
||||
<el-tag :type="step.conversionRate >= 80 ? 'success' : step.conversionRate >= 50 ? 'warning' : 'danger'" size="small">
|
||||
{{ step.conversionRate.toFixed(1) }}%
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="step.conversionRate"
|
||||
:stroke-width="16"
|
||||
:color="funnelColor(idx)"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -71,9 +75,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { bugCollectApi, type BugCollectFunnelStep } from '@/api/bugcollect'
|
||||
import { useBugCollectApp } from '@/composables/useBugCollectApp'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const { apps, loadingApps, appKey, setApp, gateStatus, applyDialogVisible, applyReason, applyLoading, submitActivation } = useBugCollectApp()
|
||||
|
||||
@ -81,6 +86,11 @@ const steps = ref(['', ''])
|
||||
const funnelData = ref<BugCollectFunnelStep[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const funnelChartRef = ref<HTMLElement | null>(null)
|
||||
let funnelChart: echarts.ECharts | null = null
|
||||
|
||||
const FUNNEL_COLORS = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399', '#b37feb', '#36cfc9']
|
||||
|
||||
function addStep() {
|
||||
steps.value.push('')
|
||||
}
|
||||
@ -90,8 +100,7 @@ function removeStep(idx: number) {
|
||||
}
|
||||
|
||||
function funnelColor(idx: number) {
|
||||
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
|
||||
return colors[idx % colors.length]
|
||||
return FUNNEL_COLORS[idx % FUNNEL_COLORS.length]
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
@ -101,12 +110,66 @@ async function analyze() {
|
||||
try {
|
||||
const res = await bugCollectApi.funnel(appKey.value, validSteps)
|
||||
funnelData.value = res.data.data ?? []
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch {
|
||||
funnelData.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!funnelChartRef.value || funnelData.value.length === 0) return
|
||||
if (!funnelChart) funnelChart = echarts.init(funnelChartRef.value)
|
||||
|
||||
funnelChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} 人 ({d}%)',
|
||||
},
|
||||
series: [{
|
||||
type: 'funnel',
|
||||
left: '10%',
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
width: '80%',
|
||||
min: 0,
|
||||
max: funnelData.value[0]?.count ?? 100,
|
||||
minSize: '0%',
|
||||
maxSize: '100%',
|
||||
sort: 'descending',
|
||||
gap: 4,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: '{b}\n{c} 人',
|
||||
},
|
||||
labelLine: { show: false },
|
||||
emphasis: {
|
||||
label: { fontSize: 16 },
|
||||
},
|
||||
data: funnelData.value.map((d, i) => ({
|
||||
name: d.eventName,
|
||||
value: d.count,
|
||||
itemStyle: { color: FUNNEL_COLORS[i % FUNNEL_COLORS.length] },
|
||||
})),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function handleChartResize() {
|
||||
funnelChart?.resize()
|
||||
}
|
||||
|
||||
watch(gateStatus, (s) => { if (s === 'enabled') { /* ready */ } })
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleChartResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleChartResize)
|
||||
funnelChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -128,29 +191,52 @@ async function analyze() {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.funnel-result {
|
||||
max-width: 600px;
|
||||
.funnel-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
min-height: 300px;
|
||||
}
|
||||
.funnel-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
.funnel-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
.funnel-item {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.funnel-step-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.funnel-step-index {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #409EFF;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.funnel-step-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.funnel-step-count {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
.funnel-drop {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -45,6 +45,12 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- Trend Chart -->
|
||||
<el-card style="margin-bottom:16px">
|
||||
<template #header>趋势(近 14 天)</template>
|
||||
<div ref="trendChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
|
||||
<!-- Exception + Stack Trace -->
|
||||
<el-card v-if="latestException" style="margin-bottom:16px">
|
||||
<template #header>
|
||||
@ -110,10 +116,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { bugCollectApi, type BugCollectIssueDetail, type BugCollectExceptionInfo } from '@/api/bugcollect'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -121,6 +128,10 @@ const detail = ref<BugCollectIssueDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
|
||||
// Trend chart
|
||||
const trendChartRef = ref<HTMLElement | null>(null)
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
|
||||
const latestException = computed<BugCollectExceptionInfo | null>(() => {
|
||||
for (const e of detail.value?.events ?? []) {
|
||||
if (e.exception?.stacktrace || e.exception?.value) return e.exception!
|
||||
@ -230,12 +241,64 @@ onMounted(async () => {
|
||||
try {
|
||||
const res = await bugCollectApi.issueDetail(id)
|
||||
detail.value = res.data.data
|
||||
await nextTick()
|
||||
loadTrend()
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function loadTrend() {
|
||||
const id = route.params.id as string
|
||||
if (!id || !trendChartRef.value) return
|
||||
try {
|
||||
const res = await bugCollectApi.issueTrend(id)
|
||||
const trend = res.data.data
|
||||
if (!trend || !trendChartRef.value) return
|
||||
|
||||
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['事件数', '影响用户'] },
|
||||
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
||||
xAxis: { type: 'category', data: trend.map(d => d.date) },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '事件数', minInterval: 1 },
|
||||
{ type: 'value', name: '影响用户', minInterval: 1 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '事件数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: trend.map(d => d.count),
|
||||
areaStyle: { opacity: 0.15 },
|
||||
},
|
||||
{
|
||||
name: '影响用户',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
data: trend.map(d => d.affectedUsers),
|
||||
areaStyle: { opacity: 0.15 },
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch {
|
||||
// trend loading failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
function handleChartResize() {
|
||||
trendChart?.resize()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleChartResize)
|
||||
trendChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -260,4 +323,8 @@ onMounted(async () => {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -26,12 +26,11 @@
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar responsive-toolbar">
|
||||
<el-select v-model="filters.type" placeholder="错误类型" style="width: 150px" clearable @change="loadData">
|
||||
<el-option label="全部类型" value="" />
|
||||
<el-option label="CRASH" value="CRASH" />
|
||||
<el-option label="ERROR" value="ERROR" />
|
||||
<el-option label="ANR" value="ANR" />
|
||||
<el-option label="WARNING" value="WARNING" />
|
||||
<el-select v-model="filters.level" placeholder="错误级别" style="width: 150px" clearable @change="loadData">
|
||||
<el-option label="全部级别" value="" />
|
||||
<el-option label="fatal" value="fatal" />
|
||||
<el-option label="error" value="error" />
|
||||
<el-option label="warning" value="warning" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.platform" placeholder="平台" style="width: 150px" clearable @change="loadData">
|
||||
<el-option label="全部平台" value="" />
|
||||
@ -61,13 +60,13 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="110">
|
||||
<el-table-column prop="level" label="级别" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="issueTypeTag(row.type)">{{ row.type }}</el-tag>
|
||||
<el-tag size="small" :type="levelTag(row.level)">{{ row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="次数" width="100" sortable />
|
||||
<el-table-column prop="isResolved" label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="row.isResolved ? 'success' : 'danger'">{{ row.isResolved ? '已解决' : '未解决' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="110" />
|
||||
<el-table-column prop="lastSeenAt" label="最后出现" width="170" sortable>
|
||||
<template #default="{ row }">
|
||||
@ -113,19 +112,33 @@ const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const filters = ref({
|
||||
type: '',
|
||||
level: '',
|
||||
platform: '',
|
||||
dateRange: null as [string, string] | null,
|
||||
})
|
||||
|
||||
function issueTypeTag(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
CRASH: 'danger',
|
||||
ERROR: 'warning',
|
||||
ANR: 'danger',
|
||||
WARNING: '',
|
||||
function levelTag(level: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
fatal: 'danger',
|
||||
error: 'warning',
|
||||
warning: '',
|
||||
info: 'info',
|
||||
}
|
||||
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||
return map[level?.toLowerCase()] ?? ''
|
||||
}
|
||||
|
||||
function statusTag(status: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
open: 'danger',
|
||||
resolved: 'success',
|
||||
ignored: 'info',
|
||||
}
|
||||
return map[status] ?? ''
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { open: '未解决', resolved: '已解决', ignored: '已忽略' }
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function formatTime(ts: string) {
|
||||
@ -138,7 +151,7 @@ async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await bugCollectApi.issues(appKey.value, {
|
||||
level: filters.value.type || undefined,
|
||||
level: filters.value.level || undefined,
|
||||
platform: filters.value.platform || undefined,
|
||||
startDate: filters.value.dateRange?.[0] || undefined,
|
||||
endDate: filters.value.dateRange?.[1] || undefined,
|
||||
|
||||
@ -14,6 +14,11 @@
|
||||
>
|
||||
<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" />
|
||||
@ -49,32 +54,45 @@
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<el-statistic title="崩溃率" :value="overview.crashRate" :precision="2" suffix="%" />
|
||||
<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>
|
||||
|
||||
<!-- Crash Rate Trend -->
|
||||
<el-card style="margin-top: 16px">
|
||||
<template #header>崩溃率趋势(近 7 天)</template>
|
||||
<div v-if="overview.crashRateTrend.length" class="trend-chart">
|
||||
<div
|
||||
v-for="item in overview.crashRateTrend"
|
||||
:key="item.date"
|
||||
class="trend-bar-wrap"
|
||||
>
|
||||
<div class="trend-bar-track">
|
||||
<div
|
||||
class="trend-bar"
|
||||
:style="{ height: barHeight(item.rate) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="trend-label">{{ item.date.slice(5) }}</div>
|
||||
<div class="trend-value">{{ item.rate.toFixed(2) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</el-card>
|
||||
<!-- 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">
|
||||
@ -88,7 +106,7 @@
|
||||
<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="issueTypeTag(row.type)">{{ row.type }}</el-tag>
|
||||
<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 />
|
||||
@ -111,14 +129,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { bugCollectApi, type BugCollectOverview } from '@/api/bugcollect'
|
||||
import { Warning, Plus, User } from '@element-plus/icons-vue'
|
||||
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 * as echarts from 'echarts'
|
||||
|
||||
const { apps, loadingApps, appKey, setApp, gateStatus, applyDialogVisible, applyReason, applyLoading, submitActivation } = useBugCollectApp()
|
||||
|
||||
const EMPTY: BugCollectOverview = {
|
||||
const statsDays = ref(7)
|
||||
|
||||
const EMPTY_OVERVIEW: BugCollectOverview = {
|
||||
totalIssues: 0,
|
||||
todayNewIssues: 0,
|
||||
affectedUsers: 0,
|
||||
@ -127,33 +148,58 @@ const EMPTY: BugCollectOverview = {
|
||||
topIssues: [],
|
||||
}
|
||||
|
||||
const overview = ref<BugCollectOverview>({ ...EMPTY })
|
||||
|
||||
const maxRate = computed(() => {
|
||||
const trend = overview.value.crashRateTrend ?? []
|
||||
const rates = trend.map((i) => i.rate)
|
||||
return Math.max(...rates, 1)
|
||||
})
|
||||
|
||||
function barHeight(rate: number) {
|
||||
return Math.min((rate / maxRate.value) * 100, 100)
|
||||
const EMPTY_STATS: BugCollectStatistics = {
|
||||
levelDistribution: [],
|
||||
statusDistribution: [],
|
||||
platformDistribution: [],
|
||||
versionDistribution: [],
|
||||
dailyTrend: [],
|
||||
}
|
||||
|
||||
function issueTypeTag(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
CRASH: 'danger',
|
||||
ERROR: 'warning',
|
||||
ANR: 'danger',
|
||||
WARNING: '',
|
||||
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 levelTag(level: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
fatal: 'danger',
|
||||
error: 'warning',
|
||||
warning: '',
|
||||
info: 'info',
|
||||
}
|
||||
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||
return map[level?.toLowerCase()] ?? ''
|
||||
}
|
||||
|
||||
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 res = await bugCollectApi.overview(appKey.value)
|
||||
const d = res.data.data
|
||||
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,
|
||||
@ -162,60 +208,170 @@ async function loadData() {
|
||||
crashRateTrend: d?.crashRateTrend ?? [],
|
||||
topIssues: d?.topIssues ?? [],
|
||||
}
|
||||
|
||||
stats.value = statsRes.data.data ?? EMPTY_STATS
|
||||
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch {
|
||||
overview.value = { ...EMPTY }
|
||||
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)
|
||||
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; }
|
||||
.trend-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
height: 200px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.trend-bar-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.trend-bar-track {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 48px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.trend-bar {
|
||||
width: 100%;
|
||||
background: linear-gradient(180deg, #409eff 0%, #79bbff 100%);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 4px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.trend-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.trend-value {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.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>
|
||||
|
||||
@ -24,6 +24,12 @@
|
||||
</div>
|
||||
<template v-else>
|
||||
|
||||
<!-- Bar Chart -->
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<template #header>高频错误 Top 20</template>
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table :data="rankings" v-loading="loading" border stripe>
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
@ -34,14 +40,14 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="110">
|
||||
<el-table-column prop="level" label="级别" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="issueTypeTag(row.type)">{{ row.type }}</el-tag>
|
||||
<el-tag size="small" :type="levelTag(row.level)">{{ row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="110" />
|
||||
<el-table-column prop="count" label="次数" width="100" sortable />
|
||||
<el-table-column prop="isResolved" label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="row.isResolved ? 'success' : 'danger'">{{ row.isResolved ? '已解决' : '未解决' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="lastSeenAt" label="最后出现" width="170">
|
||||
<template #default="{ row }">
|
||||
<span class="time-text">{{ formatTime(row.lastSeenAt) }}</span>
|
||||
@ -61,23 +67,46 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { bugCollectApi, type BugCollectIssueRanking } from '@/api/bugcollect'
|
||||
import { useBugCollectApp } from '@/composables/useBugCollectApp'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const { apps, loadingApps, appKey, setApp, gateStatus, applyDialogVisible, applyReason, applyLoading, submitActivation } = useBugCollectApp()
|
||||
|
||||
const rankings = ref<BugCollectIssueRanking[]>([])
|
||||
const loading = ref(false)
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function issueTypeTag(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
CRASH: 'danger',
|
||||
ERROR: 'warning',
|
||||
ANR: 'danger',
|
||||
WARNING: '',
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
fatal: '#F56C6C',
|
||||
error: '#E6A23C',
|
||||
warning: '#409EFF',
|
||||
}
|
||||
|
||||
function levelTag(level: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
fatal: 'danger',
|
||||
error: 'warning',
|
||||
warning: '',
|
||||
info: 'info',
|
||||
}
|
||||
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||
return map[level?.toLowerCase()] ?? ''
|
||||
}
|
||||
|
||||
function statusTag(status: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
open: 'danger',
|
||||
resolved: 'success',
|
||||
ignored: 'info',
|
||||
}
|
||||
return map[status] ?? ''
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { open: '未解决', resolved: '已解决', ignored: '已忽略' }
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function formatTime(ts: string) {
|
||||
@ -85,20 +114,64 @@ function formatTime(ts: string) {
|
||||
return new Date(ts).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function truncateTitle(title: string, maxLen = 30): string {
|
||||
if (!title) return ''
|
||||
return title.length > maxLen ? title.slice(0, maxLen) + '...' : title
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!appKey.value || gateStatus.value !== 'enabled') return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await bugCollectApi.frequencyRanking(appKey.value)
|
||||
const res = await bugCollectApi.frequencyRanking(appKey.value, { limit: 20 })
|
||||
rankings.value = res.data.data ?? []
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch {
|
||||
rankings.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chartRef.value || rankings.value.length === 0) return
|
||||
if (!chart) chart = echarts.init(chartRef.value)
|
||||
|
||||
const data = [...rankings.value].reverse()
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 200, right: 40, top: 10, bottom: 30 },
|
||||
xAxis: { type: 'value', minInterval: 1 },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => truncateTitle(d.title, 25)),
|
||||
axisLabel: { width: 180, overflow: 'truncate', fontSize: 12 },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: data.map(d => ({
|
||||
value: d.count,
|
||||
itemStyle: { color: LEVEL_COLORS[d.level] ?? '#409EFF' },
|
||||
})),
|
||||
label: { show: true, position: 'right', formatter: '{c}' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function handleChartResize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
watch(gateStatus, (s) => { if (s === 'enabled') loadData() })
|
||||
onMounted(loadData)
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
window.addEventListener('resize', handleChartResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleChartResize)
|
||||
chart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -108,4 +181,8 @@ onMounted(loadData)
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -24,6 +24,12 @@
|
||||
</div>
|
||||
<template v-else>
|
||||
|
||||
<!-- Bar Chart -->
|
||||
<el-card shadow="never" style="margin-bottom:16px">
|
||||
<template #header>高危错误 Top 20(风险分 = 次数 × 影响用户 × 级别权重)</template>
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table :data="rankings" v-loading="loading" border stripe>
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
@ -34,9 +40,9 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="110">
|
||||
<el-table-column prop="level" label="级别" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="issueTypeTag(row.type)">{{ row.type }}</el-tag>
|
||||
<el-tag size="small" :type="levelTag(row.level)">{{ row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="110" />
|
||||
@ -46,7 +52,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="次数" width="100" sortable />
|
||||
<el-table-column prop="isResolved" label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="row.isResolved ? 'success' : 'danger'">{{ row.isResolved ? '已解决' : '未解决' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="lastSeenAt" label="最后出现" width="170">
|
||||
<template #default="{ row }">
|
||||
<span class="time-text">{{ formatTime(row.lastSeenAt) }}</span>
|
||||
@ -66,23 +72,46 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { bugCollectApi, type BugCollectIssueRanking } from '@/api/bugcollect'
|
||||
import { useBugCollectApp } from '@/composables/useBugCollectApp'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const { apps, loadingApps, appKey, setApp, gateStatus, applyDialogVisible, applyReason, applyLoading, submitActivation } = useBugCollectApp()
|
||||
|
||||
const rankings = ref<BugCollectIssueRanking[]>([])
|
||||
const loading = ref(false)
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function issueTypeTag(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
CRASH: 'danger',
|
||||
ERROR: 'warning',
|
||||
ANR: 'danger',
|
||||
WARNING: '',
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
fatal: '#F56C6C',
|
||||
error: '#E6A23C',
|
||||
warning: '#409EFF',
|
||||
}
|
||||
|
||||
function levelTag(level: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
fatal: 'danger',
|
||||
error: 'warning',
|
||||
warning: '',
|
||||
info: 'info',
|
||||
}
|
||||
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||
return map[level?.toLowerCase()] ?? ''
|
||||
}
|
||||
|
||||
function statusTag(status: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
open: 'danger',
|
||||
resolved: 'success',
|
||||
ignored: 'info',
|
||||
}
|
||||
return map[status] ?? ''
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { open: '未解决', resolved: '已解决', ignored: '已忽略' }
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function riskTagType(score?: number) {
|
||||
@ -97,20 +126,64 @@ function formatTime(ts: string) {
|
||||
return new Date(ts).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function truncateTitle(title: string, maxLen = 30): string {
|
||||
if (!title) return ''
|
||||
return title.length > maxLen ? title.slice(0, maxLen) + '...' : title
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!appKey.value || gateStatus.value !== 'enabled') return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await bugCollectApi.riskRanking(appKey.value)
|
||||
const res = await bugCollectApi.riskRanking(appKey.value, { limit: 20 })
|
||||
rankings.value = res.data.data ?? []
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch {
|
||||
rankings.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chartRef.value || rankings.value.length === 0) return
|
||||
if (!chart) chart = echarts.init(chartRef.value)
|
||||
|
||||
const data = [...rankings.value].reverse()
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 200, right: 40, top: 10, bottom: 30 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => truncateTitle(d.title, 25)),
|
||||
axisLabel: { width: 180, overflow: 'truncate', fontSize: 12 },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: data.map(d => ({
|
||||
value: d.riskScore ?? 0,
|
||||
itemStyle: { color: LEVEL_COLORS[d.level] ?? '#409EFF' },
|
||||
})),
|
||||
label: { show: true, position: 'right', formatter: '{c}' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function handleChartResize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
watch(gateStatus, (s) => { if (s === 'enabled') loadData() })
|
||||
onMounted(loadData)
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
window.addEventListener('resize', handleChartResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleChartResize)
|
||||
chart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -120,4 +193,8 @@ onMounted(loadData)
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -402,6 +402,55 @@
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Statistics -->
|
||||
<el-tab-pane label="统计" name="stats">
|
||||
<div class="toolbar responsive-toolbar">
|
||||
<el-radio-group v-model="statsPlatform" @change="loadStats" style="margin-right:12px">
|
||||
<el-radio-button value="ANDROID">Android</el-radio-button>
|
||||
<el-radio-button value="IOS">iOS</el-radio-button>
|
||||
<el-radio-button value="HARMONY">Harmony</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-select v-model="statsDays" style="width:120px;margin-right:12px" @change="loadStats">
|
||||
<el-option :value="7" label="近 7 天" />
|
||||
<el-option :value="14" label="近 14 天" />
|
||||
<el-option :value="30" label="近 30 天" />
|
||||
</el-select>
|
||||
<el-button @click="loadStats" :loading="loadingStats">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loadingStats" class="stats-grid">
|
||||
<el-card shadow="never" class="stats-card">
|
||||
<template #header><span>版本分布</span></template>
|
||||
<div ref="versionChartRef" class="chart-container"></div>
|
||||
<el-empty v-if="!loadingStats && statsVersionData.length === 0" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="stats-card">
|
||||
<template #header><span>近 {{ statsDays }} 天检测趋势</span></template>
|
||||
<div ref="trendChartRef" class="chart-container"></div>
|
||||
<el-empty v-if="!loadingStats && statsTrendData.length === 0" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="stats-card">
|
||||
<template #header><span>设备型号 Top 10</span></template>
|
||||
<div ref="modelChartRef" class="chart-container"></div>
|
||||
<el-empty v-if="!loadingStats && statsModelData.length === 0" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="stats-card">
|
||||
<template #header><span>系统版本分布</span></template>
|
||||
<div ref="osChartRef" class="chart-container"></div>
|
||||
<el-empty v-if="!loadingStats && statsOsData.length === 0" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="stats-card">
|
||||
<template #header><span>厂商分布</span></template>
|
||||
<div ref="vendorChartRef" class="chart-container"></div>
|
||||
<el-empty v-if="!loadingStats && statsVendorData.length === 0" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
@ -1125,6 +1174,31 @@ const operationLogs = ref<{
|
||||
createdAt: string
|
||||
}[]>([])
|
||||
const loadingOperationLogs = ref(false)
|
||||
|
||||
// ── Statistics ──────────────────────────────────────────────────────────
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const statsPlatform = ref<'ANDROID' | 'IOS' | 'HARMONY'>('ANDROID')
|
||||
const statsDays = ref(7)
|
||||
const loadingStats = ref(false)
|
||||
const statsVersionData = ref<{ versionCode: number; versionName: string; deviceCount: number }[]>([])
|
||||
const statsTrendData = ref<{ day: string; deviceCount: number }[]>([])
|
||||
const statsModelData = ref<{ model: string; deviceCount: number }[]>([])
|
||||
const statsOsData = ref<{ osVersion: string; deviceCount: number }[]>([])
|
||||
const statsVendorData = ref<{ vendor: string; deviceCount: number }[]>([])
|
||||
|
||||
const versionChartRef = ref<HTMLElement | null>(null)
|
||||
const trendChartRef = ref<HTMLElement | null>(null)
|
||||
const modelChartRef = ref<HTMLElement | null>(null)
|
||||
const osChartRef = ref<HTMLElement | null>(null)
|
||||
const vendorChartRef = ref<HTMLElement | null>(null)
|
||||
|
||||
let versionChart: echarts.ECharts | null = null
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
let modelChart: echarts.ECharts | null = null
|
||||
let osChart: echarts.ECharts | null = null
|
||||
let vendorChart: echarts.ECharts | null = null
|
||||
|
||||
let storeReviewReloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let storeReviewAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let storeReviewDialogPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
@ -2456,6 +2530,128 @@ async function loadOperationLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if (!appKey.value) return
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const [versionRes, deviceRes, trendRes] = await Promise.all([
|
||||
updateAdminApi.getVersionDistribution(appKey.value, statsPlatform.value, statsDays.value),
|
||||
updateAdminApi.getDeviceDistribution(appKey.value, statsPlatform.value, statsDays.value),
|
||||
updateAdminApi.getDailyTrend(appKey.value, statsPlatform.value, statsDays.value),
|
||||
])
|
||||
statsVersionData.value = versionRes.data.data
|
||||
statsModelData.value = deviceRes.data.data.models
|
||||
statsOsData.value = deviceRes.data.data.osVersions
|
||||
statsVendorData.value = deviceRes.data.data.vendors
|
||||
statsTrendData.value = trendRes.data.data
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch {
|
||||
statsVersionData.value = []
|
||||
statsTrendData.value = []
|
||||
statsModelData.value = []
|
||||
statsOsData.value = []
|
||||
statsVendorData.value = []
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleChartResize() {
|
||||
versionChart?.resize()
|
||||
trendChart?.resize()
|
||||
modelChart?.resize()
|
||||
osChart?.resize()
|
||||
vendorChart?.resize()
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
// Version distribution pie chart
|
||||
if (versionChartRef.value) {
|
||||
if (!versionChart) versionChart = echarts.init(versionChartRef.value)
|
||||
versionChart.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: statsVersionData.value.map(d => ({
|
||||
name: d.versionName || `v${d.versionCode}`,
|
||||
value: d.deviceCount,
|
||||
})),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
// Daily trend line chart
|
||||
if (trendChartRef.value) {
|
||||
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: statsTrendData.value.map(d => d.day) },
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: [{
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: statsTrendData.value.map(d => d.deviceCount),
|
||||
areaStyle: { opacity: 0.15 },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
// Model distribution bar chart
|
||||
if (modelChartRef.value) {
|
||||
if (!modelChart) modelChart = echarts.init(modelChartRef.value)
|
||||
const modelData = [...statsModelData.value].reverse()
|
||||
modelChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'value', minInterval: 1 },
|
||||
yAxis: { type: 'category', data: modelData.map(d => d.model), axisLabel: { width: 120, overflow: 'truncate' } },
|
||||
series: [{ type: 'bar', data: modelData.map(d => d.deviceCount) }],
|
||||
})
|
||||
}
|
||||
|
||||
// OS version pie chart
|
||||
if (osChartRef.value) {
|
||||
if (!osChart) osChart = echarts.init(osChartRef.value)
|
||||
osChart.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: statsOsData.value.map(d => ({ name: d.osVersion, value: d.deviceCount })),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
// Vendor distribution pie chart
|
||||
if (vendorChartRef.value) {
|
||||
if (!vendorChart) vendorChart = echarts.init(vendorChartRef.value)
|
||||
vendorChart.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: statsVendorData.value.map(d => ({ name: d.vendor, value: d.deviceCount })),
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStoreReviewReload() {
|
||||
if (storeReviewReloadTimer) {
|
||||
clearTimeout(storeReviewReloadTimer)
|
||||
@ -2505,6 +2701,12 @@ watch(appKey, (key) => {
|
||||
if (isServicesPortal.value && key) checkServiceEnabled()
|
||||
})
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'stats') {
|
||||
loadStats()
|
||||
}
|
||||
})
|
||||
|
||||
watch(app, (value) => {
|
||||
if (value?.packageName) {
|
||||
appUploadForm.value.packageName = value.packageName
|
||||
@ -2529,6 +2731,7 @@ watch(grayForm, () => {
|
||||
onMounted(() => {
|
||||
updateViewport()
|
||||
window.addEventListener('resize', updateViewport)
|
||||
window.addEventListener('resize', handleChartResize)
|
||||
if (isServicesPortal.value) {
|
||||
appApi.list().then(res => {
|
||||
portalApps.value = res.data.data
|
||||
@ -2619,6 +2822,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateViewport)
|
||||
window.removeEventListener('resize', handleChartResize)
|
||||
disconnectStoreReviewRealtime()
|
||||
if (storeReviewReloadTimer) {
|
||||
clearTimeout(storeReviewReloadTimer)
|
||||
@ -2629,6 +2833,11 @@ onBeforeUnmount(() => {
|
||||
storeReviewAutoRefreshTimer = null
|
||||
}
|
||||
stopDialogPoll()
|
||||
versionChart?.dispose()
|
||||
trendChart?.dispose()
|
||||
modelChart?.dispose()
|
||||
osChart?.dispose()
|
||||
vendorChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -3298,4 +3507,20 @@ onBeforeUnmount(() => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Statistics grid ─────────────────────────────────────────────── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
</style>
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户