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>
这个提交包含在:
XuqmGroup 2026-06-19 01:23:32 +08:00
父节点 bb52b8e0b7
当前提交 1dc188b2be
共有 10 个文件被更改,包括 909 次插入162 次删除

查看文件

@ -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) }}% &darr;
<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>