feat: 日志监控模块 — 8 个页面
- 概览仪表盘(统计卡片 + 趋势图 + Top5) - 错误列表(分页 + 筛选) - 错误详情(符号化 stack + 源码上下文) - 事件流水(分页 + 筛选) - 漏斗分析(动态步骤 + 转化率) - Webhook 配置(CRUD) - 高频/高危排行 Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
父节点
23b600987b
当前提交
2d7b1943cd
146
tenant-platform/src/api/log.ts
普通文件
146
tenant-platform/src/api/log.ts
普通文件
@ -0,0 +1,146 @@
|
|||||||
|
import client from './client'
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LogOverview {
|
||||||
|
totalIssues: number
|
||||||
|
todayNewIssues: number
|
||||||
|
affectedUsers: number
|
||||||
|
crashRate: number
|
||||||
|
crashRateTrend: { date: string; rate: number }[]
|
||||||
|
topIssues: { id: string; title: string; type: string; count: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogIssue {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
platform: string
|
||||||
|
count: number
|
||||||
|
affectedUsers: number
|
||||||
|
lastSeenAt: string
|
||||||
|
firstSeenAt: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogIssueDetail {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
platform: string
|
||||||
|
appVersion: string
|
||||||
|
osVersion: string
|
||||||
|
deviceModel: string
|
||||||
|
count: number
|
||||||
|
affectedUsers: number
|
||||||
|
firstSeenAt: string
|
||||||
|
lastSeenAt: string
|
||||||
|
status: string
|
||||||
|
stackTrace: string
|
||||||
|
sourceContext?: { line: number; content: string; highlight: boolean }[]
|
||||||
|
recentEvents: LogEventItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEventItem {
|
||||||
|
id: string
|
||||||
|
eventName: string
|
||||||
|
userId: string
|
||||||
|
timestamp: string
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogIssueRanking {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
platform: string
|
||||||
|
count: number
|
||||||
|
affectedUsers: number
|
||||||
|
riskScore?: number
|
||||||
|
lastSeenAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFunnelStep {
|
||||||
|
eventName: string
|
||||||
|
count: number
|
||||||
|
conversionRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogWebhook {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
eventTypes: string[]
|
||||||
|
cooldownSeconds: number
|
||||||
|
enabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogPageResult<T> {
|
||||||
|
content: T[]
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
totalElements: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const logApi = {
|
||||||
|
// Overview
|
||||||
|
overview: () => client.get<{ data: LogOverview }>('/log/v1/overview'),
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
issues(params: {
|
||||||
|
type?: string
|
||||||
|
platform?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}) {
|
||||||
|
return client.get<{ data: LogPageResult<LogIssue> }>('/log/v1/issues', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
issueDetail(id: string) {
|
||||||
|
return client.get<{ data: LogIssueDetail }>(`/log/v1/issues/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rankings
|
||||||
|
frequencyRanking() {
|
||||||
|
return client.get<{ data: LogIssueRanking[] }>('/log/v1/issues/rankings/frequency')
|
||||||
|
},
|
||||||
|
|
||||||
|
riskRanking() {
|
||||||
|
return client.get<{ data: LogIssueRanking[] }>('/log/v1/issues/rankings/risk')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Events
|
||||||
|
events(params: {
|
||||||
|
eventName?: string
|
||||||
|
userId?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}) {
|
||||||
|
return client.get<{ data: LogPageResult<LogEventItem> }>('/log/v1/events', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Funnel
|
||||||
|
funnel(steps: string[]) {
|
||||||
|
return client.get<{ data: LogFunnelStep[] }>('/log/v1/events/funnel', {
|
||||||
|
params: { steps: steps.join(',') },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
webhooks: {
|
||||||
|
list: () => client.get<{ data: LogWebhook[] }>('/log/v1/webhooks'),
|
||||||
|
create: (data: Omit<LogWebhook, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||||
|
client.post<{ data: LogWebhook }>('/log/v1/webhooks', data),
|
||||||
|
update: (id: string, data: Partial<Omit<LogWebhook, 'id' | 'createdAt' | 'updatedAt'>>) =>
|
||||||
|
client.put<{ data: LogWebhook }>(`/log/v1/webhooks/${id}`, data),
|
||||||
|
delete: (id: string) => client.delete(`/log/v1/webhooks/${id}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -113,6 +113,39 @@ const router = createRouter({
|
|||||||
path: 'accounts',
|
path: 'accounts',
|
||||||
component: () => import('@/views/accounts/SubAccountView.vue'),
|
component: () => import('@/views/accounts/SubAccountView.vue'),
|
||||||
},
|
},
|
||||||
|
// ── 日志监控 ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
path: 'log/overview',
|
||||||
|
component: () => import('@/views/log-monitor/LogOverview.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/issues',
|
||||||
|
component: () => import('@/views/log-monitor/LogIssues.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/issues/:id',
|
||||||
|
component: () => import('@/views/log-monitor/LogIssueDetail.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/events',
|
||||||
|
component: () => import('@/views/log-monitor/LogEvents.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/funnels',
|
||||||
|
component: () => import('@/views/log-monitor/LogFunnels.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/webhooks',
|
||||||
|
component: () => import('@/views/log-monitor/LogWebhooks.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/rank/freq',
|
||||||
|
component: () => import('@/views/log-monitor/LogRankFreq.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/rank/risk',
|
||||||
|
component: () => import('@/views/log-monitor/LogRankRisk.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -29,6 +29,16 @@
|
|||||||
<el-menu-item v-if="isPrivateDeploy" index="/database"><el-icon><Coin /></el-icon><span>数据库管理</span></el-menu-item>
|
<el-menu-item v-if="isPrivateDeploy" index="/database"><el-icon><Coin /></el-icon><span>数据库管理</span></el-menu-item>
|
||||||
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
<el-sub-menu index="log-monitor">
|
||||||
|
<template #title><el-icon><DataLine /></el-icon><span>日志监控</span></template>
|
||||||
|
<el-menu-item index="/log/overview"><el-icon><Odometer /></el-icon><span>概览</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/issues"><el-icon><Warning /></el-icon><span>错误列表</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/events"><el-icon><List /></el-icon><span>事件流水</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/funnels"><el-icon><Filter /></el-icon><span>漏斗分析</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/rank/freq"><el-icon><Sort /></el-icon><span>高频排行</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/rank/risk"><el-icon><AlertTriangle /></el-icon><span>高危排行</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/webhooks"><el-icon><Link /></el-icon><span>Webhook</span></el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
@ -69,6 +79,16 @@
|
|||||||
<el-menu-item v-if="isPrivateDeploy" index="/database"><el-icon><Coin /></el-icon><span>数据库管理</span></el-menu-item>
|
<el-menu-item v-if="isPrivateDeploy" index="/database"><el-icon><Coin /></el-icon><span>数据库管理</span></el-menu-item>
|
||||||
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
<el-sub-menu index="log-monitor">
|
||||||
|
<template #title><el-icon><DataLine /></el-icon><span>日志监控</span></template>
|
||||||
|
<el-menu-item index="/log/overview"><el-icon><Odometer /></el-icon><span>概览</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/issues"><el-icon><Warning /></el-icon><span>错误列表</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/events"><el-icon><List /></el-icon><span>事件流水</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/funnels"><el-icon><Filter /></el-icon><span>漏斗分析</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/rank/freq"><el-icon><Sort /></el-icon><span>高频排行</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/rank/risk"><el-icon><AlertTriangle /></el-icon><span>高危排行</span></el-menu-item>
|
||||||
|
<el-menu-item index="/log/webhooks"><el-icon><Link /></el-icon><span>Webhook</span></el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
@ -116,7 +136,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Bell, ChatDotRound, Coin, Document, Grid, Key, List, Lock, Menu, Monitor, Odometer, Setting, Upload, User } from '@element-plus/icons-vue'
|
import { AlertTriangle, Bell, ChatDotRound, Coin, DataLine, Document, Filter, Grid, Key, Link, List, Lock, Menu, Monitor, Odometer, Setting, Sort, Upload, User, Warning } from '@element-plus/icons-vue'
|
||||||
import { getDeploymentStatus } from '@/api/system'
|
import { getDeploymentStatus } from '@/api/system'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@ -130,6 +150,7 @@ const openedMenus = computed(() => {
|
|||||||
const menus: string[] = []
|
const menus: string[] = []
|
||||||
if (route.path.startsWith('/services/')) menus.push('services')
|
if (route.path.startsWith('/services/')) menus.push('services')
|
||||||
if (['/system-logs', '/database', '/operation-logs'].includes(route.path)) menus.push('ops')
|
if (['/system-logs', '/database', '/operation-logs'].includes(route.path)) menus.push('ops')
|
||||||
|
if (route.path.startsWith('/log/')) menus.push('log-monitor')
|
||||||
return menus
|
return menus
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px">事件流水</h2>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<div class="toolbar responsive-toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.eventName"
|
||||||
|
placeholder="事件名"
|
||||||
|
style="width: 200px"
|
||||||
|
clearable
|
||||||
|
@clear="loadData"
|
||||||
|
@keyup.enter="loadData"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="filters.userId"
|
||||||
|
placeholder="用户 ID"
|
||||||
|
style="width: 200px"
|
||||||
|
clearable
|
||||||
|
@clear="loadData"
|
||||||
|
@keyup.enter="loadData"
|
||||||
|
/>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 280px"
|
||||||
|
@change="loadData"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="loadData">搜索</el-button>
|
||||||
|
<el-button :loading="loading" @click="loadData">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="events" v-loading="loading" border stripe>
|
||||||
|
<el-table-column prop="eventName" label="事件名" width="180" />
|
||||||
|
<el-table-column prop="userId" label="用户 ID" width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="timestamp" label="时间" width="170" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="time-text">{{ formatTime(row.timestamp) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="属性" min-width="300">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-popover trigger="click" :width="420">
|
||||||
|
<template #reference>
|
||||||
|
<el-button link type="primary" size="small">查看属性</el-button>
|
||||||
|
</template>
|
||||||
|
<pre class="props-json">{{ JSON.stringify(row.properties, null, 2) }}</pre>
|
||||||
|
</el-popover>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
style="margin-top: 16px"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { logApi, type LogEventItem } from '@/api/log'
|
||||||
|
|
||||||
|
const events = ref<LogEventItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
eventName: '',
|
||||||
|
userId: '',
|
||||||
|
dateRange: null as [string, string] | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(ts: string) {
|
||||||
|
if (!ts) return '-'
|
||||||
|
return new Date(ts).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await logApi.events({
|
||||||
|
eventName: filters.value.eventName || undefined,
|
||||||
|
userId: filters.value.userId || undefined,
|
||||||
|
startDate: filters.value.dateRange?.[0] || undefined,
|
||||||
|
endDate: filters.value.dateRange?.[1] || undefined,
|
||||||
|
page: currentPage.value - 1,
|
||||||
|
size: pageSize.value,
|
||||||
|
})
|
||||||
|
const data = res.data.data
|
||||||
|
events.value = data.content
|
||||||
|
total.value = data.totalElements
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
currentPage.value = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.responsive-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.props-json {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px">漏斗分析</h2>
|
||||||
|
|
||||||
|
<el-card style="margin-bottom: 16px">
|
||||||
|
<template #header>配置漏斗步骤</template>
|
||||||
|
<div class="funnel-builder">
|
||||||
|
<div v-for="(step, idx) in steps" :key="idx" class="funnel-step-row">
|
||||||
|
<span class="step-index">步骤 {{ idx + 1 }}</span>
|
||||||
|
<el-input v-model="steps[idx]" placeholder="事件名" style="width: 280px" />
|
||||||
|
<el-button v-if="steps.length > 1" type="danger" text @click="removeStep(idx)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 12px">
|
||||||
|
<el-button @click="addStep">添加步骤</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="analyze">分析</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { logApi, type LogFunnelStep } from '@/api/log'
|
||||||
|
|
||||||
|
const steps = ref(['', ''])
|
||||||
|
const funnelData = ref<LogFunnelStep[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function addStep() {
|
||||||
|
steps.value.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStep(idx: number) {
|
||||||
|
steps.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function funnelColor(idx: number) {
|
||||||
|
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
|
||||||
|
return colors[idx % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyze() {
|
||||||
|
const validSteps = steps.value.filter((s) => s.trim())
|
||||||
|
if (validSteps.length < 2) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await logApi.funnel(validSteps)
|
||||||
|
funnelData.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.funnel-builder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.funnel-step-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.step-index {
|
||||||
|
width: 60px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.funnel-result {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.funnel-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.funnel-step-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.funnel-step-name {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="detail">
|
||||||
|
<el-page-header @back="$router.back()" :content="detail.title" style="margin-bottom: 24px" />
|
||||||
|
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<el-card style="margin-bottom: 16px">
|
||||||
|
<template #header>基本信息</template>
|
||||||
|
<el-descriptions :column="3" border>
|
||||||
|
<el-descriptions-item label="类型">
|
||||||
|
<el-tag size="small" :type="issueTypeTag(detail.type)">{{ detail.type }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="平台">{{ detail.platform }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="应用版本">{{ detail.appVersion }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="系统版本">{{ detail.osVersion }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="设备型号">{{ detail.deviceModel }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="总次数">{{ detail.count }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="影响用户">{{ detail.affectedUsers }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="首次出现">{{ formatTime(detail.firstSeenAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最后出现" :span="2">{{ formatTime(detail.lastSeenAt) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Stack Trace -->
|
||||||
|
<el-card style="margin-bottom: 16px">
|
||||||
|
<template #header>Stack Trace</template>
|
||||||
|
<pre class="stack-trace">{{ detail.stackTrace }}</pre>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Source Context -->
|
||||||
|
<el-card v-if="detail.sourceContext?.length" style="margin-bottom: 16px">
|
||||||
|
<template #header>源码上下文</template>
|
||||||
|
<div class="source-context">
|
||||||
|
<div
|
||||||
|
v-for="(line, idx) in detail.sourceContext"
|
||||||
|
:key="idx"
|
||||||
|
class="source-line"
|
||||||
|
:class="{ highlight: line.highlight }"
|
||||||
|
>
|
||||||
|
<span class="line-number">{{ line.line }}</span>
|
||||||
|
<code>{{ line.content }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Recent Events -->
|
||||||
|
<el-card>
|
||||||
|
<template #header>最近事件</template>
|
||||||
|
<el-table :data="detail.recentEvents" border stripe>
|
||||||
|
<el-table-column prop="eventName" label="事件名" width="180" />
|
||||||
|
<el-table-column prop="userId" label="用户 ID" width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="timestamp" label="时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.timestamp) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="属性" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-popover trigger="click" :width="400">
|
||||||
|
<template #reference>
|
||||||
|
<el-button link type="primary" size="small">查看</el-button>
|
||||||
|
</template>
|
||||||
|
<pre class="props-json">{{ JSON.stringify(row.properties, null, 2) }}</pre>
|
||||||
|
</el-popover>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else v-loading="loading" style="min-height: 300px" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { logApi, type LogIssueDetail } from '@/api/log'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const detail = ref<LogIssueDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function issueTypeTag(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CRASH: 'danger',
|
||||||
|
ERROR: 'warning',
|
||||||
|
ANR: 'danger',
|
||||||
|
WARNING: '',
|
||||||
|
}
|
||||||
|
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string) {
|
||||||
|
if (!ts) return '-'
|
||||||
|
return new Date(ts).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = route.params.id as string
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await logApi.issueDetail(id)
|
||||||
|
detail.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stack-trace {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.source-context {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.source-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.source-line.highlight {
|
||||||
|
background: rgba(255, 200, 0, 0.15);
|
||||||
|
border-left: 3px solid #ffc800;
|
||||||
|
}
|
||||||
|
.line-number {
|
||||||
|
width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
color: #858585;
|
||||||
|
padding-right: 12px;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.source-line code {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.props-json {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px">错误列表</h2>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<el-select v-model="filters.platform" placeholder="平台" style="width: 150px" clearable @change="loadData">
|
||||||
|
<el-option label="全部平台" value="" />
|
||||||
|
<el-option label="Android" value="Android" />
|
||||||
|
<el-option label="iOS" value="iOS" />
|
||||||
|
<el-option label="HarmonyOS" value="HarmonyOS" />
|
||||||
|
</el-select>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 280px"
|
||||||
|
@change="loadData"
|
||||||
|
/>
|
||||||
|
<el-button :loading="loading" @click="loadData">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table :data="issues" v-loading="loading" border stripe>
|
||||||
|
<el-table-column prop="title" label="标题" min-width="300" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="$router.push(`/log/issues/${row.id}`)">
|
||||||
|
{{ row.title }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="type" label="类型" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="issueTypeTag(row.type)">{{ row.type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="count" label="次数" width="100" sortable />
|
||||||
|
<el-table-column prop="affectedUsers" label="影响用户" width="110" sortable />
|
||||||
|
<el-table-column prop="platform" label="平台" width="110" />
|
||||||
|
<el-table-column prop="lastSeenAt" label="最后出现" width="170" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="time-text">{{ formatTime(row.lastSeenAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
style="margin-top: 16px"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { logApi, type LogIssue } from '@/api/log'
|
||||||
|
|
||||||
|
const issues = ref<LogIssue[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
type: '',
|
||||||
|
platform: '',
|
||||||
|
dateRange: null as [string, string] | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
function issueTypeTag(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CRASH: 'danger',
|
||||||
|
ERROR: 'warning',
|
||||||
|
ANR: 'danger',
|
||||||
|
WARNING: '',
|
||||||
|
}
|
||||||
|
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string) {
|
||||||
|
if (!ts) return '-'
|
||||||
|
return new Date(ts).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await logApi.issues({
|
||||||
|
type: filters.value.type || undefined,
|
||||||
|
platform: filters.value.platform || undefined,
|
||||||
|
startDate: filters.value.dateRange?.[0] || undefined,
|
||||||
|
endDate: filters.value.dateRange?.[1] || undefined,
|
||||||
|
page: currentPage.value - 1,
|
||||||
|
size: pageSize.value,
|
||||||
|
})
|
||||||
|
const data = res.data.data
|
||||||
|
issues.value = data.content
|
||||||
|
total.value = data.totalElements
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
currentPage.value = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.responsive-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px">日志概览</h2>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="总 Issue 数" :value="overview.totalIssues">
|
||||||
|
<template #prefix><el-icon><Warning /></el-icon></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="今日新增" :value="overview.todayNewIssues">
|
||||||
|
<template #prefix><el-icon><Plus /></el-icon></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="影响用户数" :value="overview.affectedUsers">
|
||||||
|
<template #prefix><el-icon><User /></el-icon></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="崩溃率" :value="overview.crashRate" :precision="2" suffix="%" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Top 5 Issues -->
|
||||||
|
<el-card style="margin-top: 16px">
|
||||||
|
<template #header>
|
||||||
|
<div class="toolbar toolbar-space-between">
|
||||||
|
<span>高频错误 Top 5</span>
|
||||||
|
<el-button link type="primary" @click="$router.push('/log/rank/freq')">查看全部</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table :data="overview.topIssues" border stripe>
|
||||||
|
<el-table-column prop="title" label="错误标题" min-width="300" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="type" label="类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="issueTypeTag(row.type)">{{ row.type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="count" label="次数" width="100" sortable />
|
||||||
|
<el-table-column label="操作" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="$router.push(`/log/issues/${row.id}`)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { logApi, type LogOverview } from '@/api/log'
|
||||||
|
import { Warning, Plus, User } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const overview = ref<LogOverview>({
|
||||||
|
totalIssues: 0,
|
||||||
|
todayNewIssues: 0,
|
||||||
|
affectedUsers: 0,
|
||||||
|
crashRate: 0,
|
||||||
|
crashRateTrend: [],
|
||||||
|
topIssues: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxRate = computed(() => {
|
||||||
|
const rates = overview.value.crashRateTrend.map((i) => i.rate)
|
||||||
|
return Math.max(...rates, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
function barHeight(rate: number) {
|
||||||
|
return Math.min((rate / maxRate.value) * 100, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueTypeTag(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CRASH: 'danger',
|
||||||
|
ERROR: 'warning',
|
||||||
|
ANR: 'danger',
|
||||||
|
WARNING: '',
|
||||||
|
}
|
||||||
|
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await logApi.overview()
|
||||||
|
overview.value = res.data.data
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px">高频错误排行</h2>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-table :data="rankings" v-loading="loading" border stripe>
|
||||||
|
<el-table-column type="index" label="#" width="60" />
|
||||||
|
<el-table-column prop="title" label="错误标题" min-width="300" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="$router.push(`/log/issues/${row.id}`)">
|
||||||
|
{{ row.title }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="type" label="类型" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="issueTypeTag(row.type)">{{ row.type }}</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="affectedUsers" label="影响用户" width="110" sortable />
|
||||||
|
<el-table-column prop="lastSeenAt" label="最后出现" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="time-text">{{ formatTime(row.lastSeenAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { logApi, type LogIssueRanking } from '@/api/log'
|
||||||
|
|
||||||
|
const rankings = ref<LogIssueRanking[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function issueTypeTag(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CRASH: 'danger',
|
||||||
|
ERROR: 'warning',
|
||||||
|
ANR: 'danger',
|
||||||
|
WARNING: '',
|
||||||
|
}
|
||||||
|
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string) {
|
||||||
|
if (!ts) return '-'
|
||||||
|
return new Date(ts).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await logApi.frequencyRanking()
|
||||||
|
rankings.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom: 24px">高危排行</h2>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-table :data="rankings" v-loading="loading" border stripe>
|
||||||
|
<el-table-column type="index" label="#" width="60" />
|
||||||
|
<el-table-column prop="title" label="错误标题" min-width="280" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="$router.push(`/log/issues/${row.id}`)">
|
||||||
|
{{ row.title }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="type" label="类型" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="issueTypeTag(row.type)">{{ row.type }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="platform" label="平台" width="110" />
|
||||||
|
<el-table-column prop="riskScore" label="风险分" width="100" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="riskTagType(row.riskScore)" size="small">{{ row.riskScore }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="count" label="次数" width="100" sortable />
|
||||||
|
<el-table-column prop="affectedUsers" label="影响用户" width="110" sortable />
|
||||||
|
<el-table-column prop="lastSeenAt" label="最后出现" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="time-text">{{ formatTime(row.lastSeenAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { logApi, type LogIssueRanking } from '@/api/log'
|
||||||
|
|
||||||
|
const rankings = ref<LogIssueRanking[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function issueTypeTag(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
CRASH: 'danger',
|
||||||
|
ERROR: 'warning',
|
||||||
|
ANR: 'danger',
|
||||||
|
WARNING: '',
|
||||||
|
}
|
||||||
|
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskTagType(score?: number) {
|
||||||
|
if (!score) return 'info'
|
||||||
|
if (score >= 80) return 'danger'
|
||||||
|
if (score >= 50) return 'warning'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string) {
|
||||||
|
if (!ts) return '-'
|
||||||
|
return new Date(ts).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await logApi.riskRanking()
|
||||||
|
rankings.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="toolbar toolbar-space-between" style="margin-bottom: 24px">
|
||||||
|
<h2 style="margin: 0">Webhook 配置</h2>
|
||||||
|
<el-button type="primary" @click="openDialog()">新增 Webhook</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-table :data="webhooks" v-loading="loading" border stripe>
|
||||||
|
<el-table-column prop="url" label="回调地址" min-width="300" show-overflow-tooltip />
|
||||||
|
<el-table-column label="事件类型" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="t in row.eventTypes" :key="t" size="small" style="margin-right: 4px">{{ t }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="cooldownSeconds" label="冷却(秒)" width="110" />
|
||||||
|
<el-table-column prop="enabled" label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.enabled ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openDialog(row)">编辑</el-button>
|
||||||
|
<el-popconfirm title="确认删除?" @confirm="handleDelete(row.id)">
|
||||||
|
<template #reference>
|
||||||
|
<el-button link type="danger" size="small">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- Dialog -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editingId ? '编辑 Webhook' : '新增 Webhook'"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form :model="form" label-width="100px">
|
||||||
|
<el-form-item label="回调地址" required>
|
||||||
|
<el-input v-model="form.url" placeholder="https://example.com/webhook" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="事件类型" required>
|
||||||
|
<el-select v-model="form.eventTypes" multiple style="width: 100%" placeholder="选择事件类型">
|
||||||
|
<el-option label="ISSUE_NEW" value="ISSUE_NEW" />
|
||||||
|
<el-option label="ISSUE_RESOLVED" value="ISSUE_RESOLVED" />
|
||||||
|
<el-option label="CRASH_SPIKE" value="CRASH_SPIKE" />
|
||||||
|
<el-option label="ALL" value="ALL" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="冷却时间">
|
||||||
|
<el-input-number v-model="form.cooldownSeconds" :min="0" :max="3600" :step="60" />
|
||||||
|
<span style="margin-left: 8px; color: #999">秒</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="form.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { logApi, type LogWebhook } from '@/api/log'
|
||||||
|
|
||||||
|
const webhooks = ref<LogWebhook[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
url: '',
|
||||||
|
eventTypes: [] as string[],
|
||||||
|
cooldownSeconds: 300,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadWebhooks() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await logApi.webhooks.list()
|
||||||
|
webhooks.value = res.data.data
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(row?: LogWebhook) {
|
||||||
|
if (row) {
|
||||||
|
editingId.value = row.id
|
||||||
|
form.value = {
|
||||||
|
url: row.url,
|
||||||
|
eventTypes: [...row.eventTypes],
|
||||||
|
cooldownSeconds: row.cooldownSeconds,
|
||||||
|
enabled: row.enabled,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editingId.value = ''
|
||||||
|
form.value = { url: '', eventTypes: [], cooldownSeconds: 300, enabled: true }
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.value.url || !form.value.eventTypes.length) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await logApi.webhooks.update(editingId.value, form.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await logApi.webhooks.create(form.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadWebhooks()
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await logApi.webhooks.delete(id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadWebhooks()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadWebhooks)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar-space-between {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -42,6 +42,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/api/log': {
|
||||||
|
target: 'http://127.0.0.1:8087',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/api/license': {
|
'/api/license': {
|
||||||
target: 'http://127.0.0.1:8085',
|
target: 'http://127.0.0.1:8085',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户