feat(log): 重构日志监控功能为Bug收集系统
- 将日志监控模块重命名为Bug收集模块 - 更新路由路径从 /log/* 到 /bugcollect/* - 修改导航菜单项名称为"Bug收集" - 更新API代理路径从 /api/log 到 /api/bugcollect - 新增完整的Bug收集前端功能实现 - 添加错误列表、概览、事件流水、漏斗分析等功能页面 - 实现错误详情展示包括堆栈追踪和源码上下文 - 创建新的bugcollect API接口文件和类型定义
这个提交包含在:
父节点
1ad3cc481d
当前提交
1abf05ecca
@ -0,0 +1,146 @@
|
||||
import client from './client'
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BugCollectOverview {
|
||||
totalIssues: number
|
||||
todayNewIssues: number
|
||||
affectedUsers: number
|
||||
crashRate: number
|
||||
crashRateTrend: { date: string; rate: number }[]
|
||||
topIssues: { id: string; title: string; type: string; count: number }[]
|
||||
}
|
||||
|
||||
export interface BugCollectIssue {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
platform: string
|
||||
count: number
|
||||
affectedUsers: number
|
||||
lastSeenAt: string
|
||||
firstSeenAt: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface BugCollectIssueDetail {
|
||||
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: BugCollectEventItem[]
|
||||
}
|
||||
|
||||
export interface BugCollectEventItem {
|
||||
id: string
|
||||
eventName: string
|
||||
userId: string
|
||||
timestamp: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface BugCollectIssueRanking {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
platform: string
|
||||
count: number
|
||||
affectedUsers: number
|
||||
riskScore?: number
|
||||
lastSeenAt: string
|
||||
}
|
||||
|
||||
export interface BugCollectFunnelStep {
|
||||
eventName: string
|
||||
count: number
|
||||
conversionRate: number
|
||||
}
|
||||
|
||||
export interface BugCollectWebhook {
|
||||
id: string
|
||||
url: string
|
||||
eventTypes: string[]
|
||||
cooldownSeconds: number
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BugCollectPageResult<T> {
|
||||
content: T[]
|
||||
page: number
|
||||
size: number
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// ── API ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const bugCollectApi = {
|
||||
// Overview
|
||||
overview: () => client.get<{ data: BugCollectOverview }>('/bugcollect/v1/overview'),
|
||||
|
||||
// Issues
|
||||
issues(params: {
|
||||
type?: string
|
||||
platform?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}) {
|
||||
return client.get<{ data: BugCollectPageResult<BugCollectIssue> }>('/bugcollect/v1/issues', { params })
|
||||
},
|
||||
|
||||
issueDetail(id: string) {
|
||||
return client.get<{ data: BugCollectIssueDetail }>(`/bugcollect/v1/issues/${id}`)
|
||||
},
|
||||
|
||||
// Rankings
|
||||
frequencyRanking() {
|
||||
return client.get<{ data: BugCollectIssueRanking[] }>('/bugcollect/v1/issues/rankings/frequency')
|
||||
},
|
||||
|
||||
riskRanking() {
|
||||
return client.get<{ data: BugCollectIssueRanking[] }>('/bugcollect/v1/issues/rankings/risk')
|
||||
},
|
||||
|
||||
// Events
|
||||
events(params: {
|
||||
eventName?: string
|
||||
userId?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}) {
|
||||
return client.get<{ data: BugCollectPageResult<BugCollectEventItem> }>('/bugcollect/v1/events', { params })
|
||||
},
|
||||
|
||||
// Funnel
|
||||
funnel(steps: string[]) {
|
||||
return client.get<{ data: BugCollectFunnelStep[] }>('/bugcollect/v1/events/funnel', {
|
||||
params: { steps: steps.join(',') },
|
||||
})
|
||||
},
|
||||
|
||||
// Webhooks
|
||||
webhooks: {
|
||||
list: () => client.get<{ data: BugCollectWebhook[] }>('/bugcollect/v1/webhooks'),
|
||||
create: (data: Omit<BugCollectWebhook, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||
client.post<{ data: BugCollectWebhook }>('/bugcollect/v1/webhooks', data),
|
||||
update: (id: string, data: Partial<Omit<BugCollectWebhook, 'id' | 'createdAt' | 'updatedAt'>>) =>
|
||||
client.put<{ data: BugCollectWebhook }>(`/bugcollect/v1/webhooks/${id}`, data),
|
||||
delete: (id: string) => client.delete(`/bugcollect/v1/webhooks/${id}`),
|
||||
},
|
||||
}
|
||||
@ -113,38 +113,38 @@ const router = createRouter({
|
||||
path: 'accounts',
|
||||
component: () => import('@/views/accounts/SubAccountView.vue'),
|
||||
},
|
||||
// ── 日志监控 ────────────────────────────────────────────────
|
||||
// ── Bug 收集 ─────────────────────────────────────────────────
|
||||
{
|
||||
path: 'log/overview',
|
||||
component: () => import('@/views/log-monitor/LogOverview.vue'),
|
||||
path: 'bugcollect/overview',
|
||||
component: () => import('@/views/bug-collect/BugCollectOverview.vue'),
|
||||
},
|
||||
{
|
||||
path: 'log/issues',
|
||||
component: () => import('@/views/log-monitor/LogIssues.vue'),
|
||||
path: 'bugcollect/issues',
|
||||
component: () => import('@/views/bug-collect/BugCollectIssues.vue'),
|
||||
},
|
||||
{
|
||||
path: 'log/issues/:id',
|
||||
component: () => import('@/views/log-monitor/LogIssueDetail.vue'),
|
||||
path: 'bugcollect/issues/:id',
|
||||
component: () => import('@/views/bug-collect/BugCollectIssueDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'log/events',
|
||||
component: () => import('@/views/log-monitor/LogEvents.vue'),
|
||||
path: 'bugcollect/events',
|
||||
component: () => import('@/views/bug-collect/BugCollectEvents.vue'),
|
||||
},
|
||||
{
|
||||
path: 'log/funnels',
|
||||
component: () => import('@/views/log-monitor/LogFunnels.vue'),
|
||||
path: 'bugcollect/funnels',
|
||||
component: () => import('@/views/bug-collect/BugCollectFunnels.vue'),
|
||||
},
|
||||
{
|
||||
path: 'log/webhooks',
|
||||
component: () => import('@/views/log-monitor/LogWebhooks.vue'),
|
||||
path: 'bugcollect/webhooks',
|
||||
component: () => import('@/views/bug-collect/BugCollectWebhooks.vue'),
|
||||
},
|
||||
{
|
||||
path: 'log/rank/freq',
|
||||
component: () => import('@/views/log-monitor/LogRankFreq.vue'),
|
||||
path: 'bugcollect/rank/freq',
|
||||
component: () => import('@/views/bug-collect/BugCollectRankFreq.vue'),
|
||||
},
|
||||
{
|
||||
path: 'log/rank/risk',
|
||||
component: () => import('@/views/log-monitor/LogRankRisk.vue'),
|
||||
path: 'bugcollect/rank/risk',
|
||||
component: () => import('@/views/bug-collect/BugCollectRankRisk.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -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 { bugCollectApi, type BugCollectEventItem } from '@/api/bugcollect'
|
||||
|
||||
const events = ref<BugCollectEventItem[]>([])
|
||||
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 bugCollectApi.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 { bugCollectApi, type BugCollectFunnelStep } from '@/api/bugcollect'
|
||||
|
||||
const steps = ref(['', ''])
|
||||
const funnelData = ref<BugCollectFunnelStep[]>([])
|
||||
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 bugCollectApi.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 { bugCollectApi, type BugCollectIssueDetail } from '@/api/bugcollect'
|
||||
|
||||
const route = useRoute()
|
||||
const detail = ref<BugCollectIssueDetail | 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 bugCollectApi.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(`/bugcollect/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 { bugCollectApi, type BugCollectIssue } from '@/api/bugcollect'
|
||||
|
||||
const issues = ref<BugCollectIssue[]>([])
|
||||
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 bugCollectApi.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">Bug 概览</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('/bugcollect/rank/freq')">查看全部</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="overview.topIssues" border stripe>
|
||||
<el-table-column prop="title" label="错误标题" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="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(`/bugcollect/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 { bugCollectApi, type BugCollectOverview } from '@/api/bugcollect'
|
||||
import { Warning, Plus, User } from '@element-plus/icons-vue'
|
||||
|
||||
const overview = ref<BugCollectOverview>({
|
||||
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 bugCollectApi.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(`/bugcollect/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 { bugCollectApi, type BugCollectIssueRanking } from '@/api/bugcollect'
|
||||
|
||||
const rankings = ref<BugCollectIssueRanking[]>([])
|
||||
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 bugCollectApi.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(`/bugcollect/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 { bugCollectApi, type BugCollectIssueRanking } from '@/api/bugcollect'
|
||||
|
||||
const rankings = ref<BugCollectIssueRanking[]>([])
|
||||
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 bugCollectApi.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 { bugCollectApi, type BugCollectWebhook } from '@/api/bugcollect'
|
||||
|
||||
const webhooks = ref<BugCollectWebhook[]>([])
|
||||
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 bugCollectApi.webhooks.list()
|
||||
webhooks.value = res.data.data
|
||||
} catch {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row?: BugCollectWebhook) {
|
||||
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 bugCollectApi.webhooks.update(editingId.value, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await bugCollectApi.webhooks.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadWebhooks()
|
||||
} catch {
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await bugCollectApi.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>
|
||||
@ -29,15 +29,15 @@
|
||||
<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-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><Warning /></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 index="bug-collect">
|
||||
<template #title><el-icon><DataLine /></el-icon><span>Bug收集</span></template>
|
||||
<el-menu-item index="/bugcollect/overview"><el-icon><Odometer /></el-icon><span>概览</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/issues"><el-icon><Warning /></el-icon><span>错误列表</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/events"><el-icon><List /></el-icon><span>事件流水</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/funnels"><el-icon><Filter /></el-icon><span>漏斗分析</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/rank/freq"><el-icon><Sort /></el-icon><span>高频排行</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/rank/risk"><el-icon><Warning /></el-icon><span>高危排行</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/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>
|
||||
@ -79,15 +79,15 @@
|
||||
<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-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><Warning /></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 index="bug-collect">
|
||||
<template #title><el-icon><DataLine /></el-icon><span>Bug收集</span></template>
|
||||
<el-menu-item index="/bugcollect/overview"><el-icon><Odometer /></el-icon><span>概览</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/issues"><el-icon><Warning /></el-icon><span>错误列表</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/events"><el-icon><List /></el-icon><span>事件流水</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/funnels"><el-icon><Filter /></el-icon><span>漏斗分析</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/rank/freq"><el-icon><Sort /></el-icon><span>高频排行</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/rank/risk"><el-icon><Warning /></el-icon><span>高危排行</span></el-menu-item>
|
||||
<el-menu-item index="/bugcollect/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>
|
||||
@ -150,7 +150,7 @@ const openedMenus = computed(() => {
|
||||
const menus: string[] = []
|
||||
if (route.path.startsWith('/services/')) menus.push('services')
|
||||
if (['/system-logs', '/database', '/operation-logs'].includes(route.path)) menus.push('ops')
|
||||
if (route.path.startsWith('/log/')) menus.push('log-monitor')
|
||||
if (route.path.startsWith('/bugcollect/')) menus.push('bug-collect')
|
||||
return menus
|
||||
})
|
||||
|
||||
|
||||
@ -42,7 +42,7 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api/log': {
|
||||
'/api/bugcollect': {
|
||||
target: 'http://127.0.0.1:8087',
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户