diff --git a/tenant-platform/package.json b/tenant-platform/package.json index e92d8e0..98be143 100644 --- a/tenant-platform/package.json +++ b/tenant-platform/package.json @@ -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", diff --git a/tenant-platform/src/api/bugcollect.ts b/tenant-platform/src/api/bugcollect.ts index 4c76dc5..af4ee29 100644 --- a/tenant-platform/src/api/bugcollect.ts +++ b/tenant-platform/src/api/bugcollect.ts @@ -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 export interface BugCollectPageResult { 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`) }, diff --git a/tenant-platform/src/api/update.ts b/tenant-platform/src/api/update.ts index c645895..5ae9b48 100644 --- a/tenant-platform/src/api/update.ts +++ b/tenant-platform/src/api/update.ts @@ -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 } }, + ) + }, } diff --git a/tenant-platform/src/views/bug-collect/BugCollectFunnels.vue b/tenant-platform/src/views/bug-collect/BugCollectFunnels.vue index 7522f0c..7074eaa 100644 --- a/tenant-platform/src/views/bug-collect/BugCollectFunnels.vue +++ b/tenant-platform/src/views/bug-collect/BugCollectFunnels.vue @@ -41,20 +41,24 @@ -
-
-
-
{{ step.eventName }}
-
{{ step.count }} 人
-
- -
- 转化 {{ step.conversionRate.toFixed(1) }}% ↓ +
+
+
+
+
+ {{ idx + 1 }} + {{ step.eventName }} + {{ step.count }} 人 + + {{ step.conversionRate.toFixed(1) }}% + +
+
@@ -71,9 +75,10 @@ diff --git a/tenant-platform/src/views/bug-collect/BugCollectIssueDetail.vue b/tenant-platform/src/views/bug-collect/BugCollectIssueDetail.vue index 3eef210..bfbe66a 100644 --- a/tenant-platform/src/views/bug-collect/BugCollectIssueDetail.vue +++ b/tenant-platform/src/views/bug-collect/BugCollectIssueDetail.vue @@ -45,6 +45,12 @@ + + + +
+
+ diff --git a/tenant-platform/src/views/bug-collect/BugCollectIssues.vue b/tenant-platform/src/views/bug-collect/BugCollectIssues.vue index 1fb8aaf..1847d78 100644 --- a/tenant-platform/src/views/bug-collect/BugCollectIssues.vue +++ b/tenant-platform/src/views/bug-collect/BugCollectIssues.vue @@ -26,12 +26,11 @@
- - - - - - + + + + + @@ -61,13 +60,13 @@ - + - + diff --git a/tenant-platform/src/views/bug-collect/BugCollectRankFreq.vue b/tenant-platform/src/views/bug-collect/BugCollectRankFreq.vue index 71152b0..aa73847 100644 --- a/tenant-platform/src/views/bug-collect/BugCollectRankFreq.vue +++ b/tenant-platform/src/views/bug-collect/BugCollectRankFreq.vue @@ -24,6 +24,12 @@
- + - + diff --git a/tenant-platform/src/views/bug-collect/BugCollectRankRisk.vue b/tenant-platform/src/views/bug-collect/BugCollectRankRisk.vue index 97e1c48..7446f45 100644 --- a/tenant-platform/src/views/bug-collect/BugCollectRankRisk.vue +++ b/tenant-platform/src/views/bug-collect/BugCollectRankRisk.vue @@ -24,6 +24,12 @@
- + @@ -46,7 +52,7 @@ - + diff --git a/tenant-platform/src/views/update/VersionManagementView.vue b/tenant-platform/src/views/update/VersionManagementView.vue index e08e3ea..aca828d 100644 --- a/tenant-platform/src/views/update/VersionManagementView.vue +++ b/tenant-platform/src/views/update/VersionManagementView.vue @@ -402,6 +402,55 @@
+ + + +
+ + Android + iOS + Harmony + + + + + + + 刷新 +
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+
@@ -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(null) +const trendChartRef = ref(null) +const modelChartRef = ref(null) +const osChartRef = ref(null) +const vendorChartRef = ref(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 | null = null let storeReviewAutoRefreshTimer: ReturnType | null = null let storeReviewDialogPollTimer: ReturnType | 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() }) @@ -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; +}