refactor: log-monitor → bug-collect 重命名

- 目录/文件/API/路由/侧边栏全部重命名
- API路径 /log/v1/ → /bugcollect/v1/

Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-16 18:33:13 +08:00
父节点 35f8d30cd5
当前提交 95a8a3f876
共有 9 个文件被更改,包括 0 次插入1204 次删除

查看文件

@ -1,146 +0,0 @@
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}`),
},
}

查看文件

@ -1,150 +0,0 @@
<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>

查看文件

@ -1,120 +0,0 @@
<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) }}% &darr;
</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>

查看文件

@ -1,159 +0,0 @@
<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>

查看文件

@ -1,151 +0,0 @@
<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>

查看文件

@ -1,169 +0,0 @@
<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>

查看文件

@ -1,72 +0,0 @@
<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>

查看文件

@ -1,84 +0,0 @@
<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>

查看文件

@ -1,153 +0,0 @@
<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>