2026-06-16 18:30:01 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div v-if="detail">
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px">
|
|
|
|
|
|
<el-page-header @back="$router.back()" :content="detail.title" />
|
|
|
|
|
|
<div style="display:flex;gap:8px">
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="detail.status !== 'resolved'"
|
|
|
|
|
|
type="success" size="small" :loading="actionLoading"
|
|
|
|
|
|
@click="handleResolve"
|
|
|
|
|
|
>标记已解决</el-button>
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="detail.status === 'resolved' || detail.status === 'ignored'"
|
|
|
|
|
|
size="small" :loading="actionLoading"
|
|
|
|
|
|
@click="handleReopen"
|
|
|
|
|
|
>重新打开</el-button>
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
v-if="detail.status !== 'ignored'"
|
|
|
|
|
|
size="small" :loading="actionLoading"
|
|
|
|
|
|
@click="handleIgnore"
|
|
|
|
|
|
>忽略</el-button>
|
|
|
|
|
|
<el-button type="danger" size="small" @click="handleDelete">删除</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Basic Info -->
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-card style="margin-bottom:16px">
|
2026-06-16 18:30:01 +08:00
|
|
|
|
<template #header>基本信息</template>
|
|
|
|
|
|
<el-descriptions :column="3" border>
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-descriptions-item label="级别">
|
|
|
|
|
|
<el-tag size="small" :type="levelTag(detail.level)">{{ detail.level ?? '-' }}</el-tag>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</el-descriptions-item>
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-descriptions-item label="平台">{{ detail.platform ?? '-' }}</el-descriptions-item>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
<el-descriptions-item label="状态">
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-tag size="small" :type="statusTag(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
</el-descriptions-item>
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-descriptions-item label="版本">{{ detail.release ?? '-' }}</el-descriptions-item>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
<el-descriptions-item label="总次数">{{ detail.count }}</el-descriptions-item>
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-descriptions-item label="影响用户数">{{ detail.affectedUsers ?? 0 }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="负责人">{{ detail.assignee ?? '未分配' }}</el-descriptions-item>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
<el-descriptions-item label="指纹">
|
|
|
|
|
|
<span style="font-family:monospace;font-size:12px">{{ detail.fingerprint ?? '-' }}</span>
|
|
|
|
|
|
</el-descriptions-item>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
<!-- Trend Chart -->
|
|
|
|
|
|
<el-card style="margin-bottom:16px">
|
|
|
|
|
|
<template #header>趋势(近 14 天)</template>
|
|
|
|
|
|
<div ref="trendChartRef" class="chart-container"></div>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<!-- Exception + Stack Trace -->
|
|
|
|
|
|
<el-card v-if="latestException" style="margin-bottom:16px">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<span>崩溃信息</span>
|
|
|
|
|
|
<span v-if="latestException.type" style="margin-left:12px;font-size:13px;color:#909399">
|
|
|
|
|
|
{{ latestException.type }}: {{ latestException.value }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<pre class="stack-trace">{{ latestException.stackSymbolicated || latestException.stacktrace || '(无堆栈信息)' }}</pre>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Device Info -->
|
|
|
|
|
|
<el-card v-if="latestDevice" style="margin-bottom:16px">
|
|
|
|
|
|
<template #header>设备信息</template>
|
|
|
|
|
|
<el-descriptions :column="3" border>
|
|
|
|
|
|
<el-descriptions-item label="型号">{{ latestDevice.model ?? '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="厂商">{{ latestDevice.manufacturer ?? '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="系统">
|
|
|
|
|
|
{{ latestDevice.osName ?? '' }} {{ latestDevice.osVersion ?? '' }}
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="语言">{{ latestDevice.locale ?? '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="时区">{{ latestDevice.timezone ?? '-' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="可用内存">
|
|
|
|
|
|
{{ latestDevice.freeMemoryMb != null ? latestDevice.freeMemoryMb + ' MB' : '-' }}
|
|
|
|
|
|
</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="模拟器">{{ latestDevice.isEmulator ? '是' : '否' }}</el-descriptions-item>
|
|
|
|
|
|
<el-descriptions-item label="构建类型">{{ latestDevice.buildType ?? '-' }}</el-descriptions-item>
|
|
|
|
|
|
</el-descriptions>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Recent Events -->
|
|
|
|
|
|
<el-card>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
<template #header>最近崩溃事件</template>
|
|
|
|
|
|
<el-table :data="detail.events ?? []" border stripe>
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-table-column label="用户 / 设备 ID" width="180" show-overflow-tooltip>
|
|
|
|
|
|
<template #default="{ row }">{{ row.userId ?? '-' }}</template>
|
|
|
|
|
|
</el-table-column>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
<el-table-column prop="platform" label="平台" width="100" />
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-table-column prop="release" label="版本" width="110" />
|
|
|
|
|
|
<el-table-column prop="environment" label="环境" width="100" />
|
|
|
|
|
|
<el-table-column label="错误信息" min-width="240" show-overflow-tooltip>
|
|
|
|
|
|
<template #default="{ row }">{{ row.exception?.value ?? '-' }}</template>
|
|
|
|
|
|
</el-table-column>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
<el-table-column prop="createdAt" label="时间" width="170">
|
|
|
|
|
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</el-table-column>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
<el-table-column label="堆栈" width="90" align="center">
|
2026-06-16 18:30:01 +08:00
|
|
|
|
<template #default="{ row }">
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<el-popover v-if="row.exception?.stacktrace" trigger="click" :width="640">
|
2026-06-16 18:30:01 +08:00
|
|
|
|
<template #reference>
|
|
|
|
|
|
<el-button link type="primary" size="small">查看</el-button>
|
|
|
|
|
|
</template>
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<pre class="props-json">{{ row.exception?.stackSymbolicated || row.exception?.stacktrace }}</pre>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</el-popover>
|
2026-06-17 10:24:01 +08:00
|
|
|
|
<span v-else style="color:#ccc">-</span>
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
|
|
|
|
|
</el-card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-18 10:47:52 +08:00
|
|
|
|
<div v-else v-loading="loading" style="min-height:300px" />
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-06-19 01:23:32 +08:00
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
2026-06-18 10:47:52 +08:00
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
|
|
import { bugCollectApi, type BugCollectIssueDetail, type BugCollectExceptionInfo } from '@/api/bugcollect'
|
2026-06-19 01:23:32 +08:00
|
|
|
|
import * as echarts from 'echarts'
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
2026-06-18 10:47:52 +08:00
|
|
|
|
const router = useRouter()
|
2026-06-16 18:30:01 +08:00
|
|
|
|
const detail = ref<BugCollectIssueDetail | null>(null)
|
|
|
|
|
|
const loading = ref(false)
|
2026-06-18 10:47:52 +08:00
|
|
|
|
const actionLoading = ref(false)
|
|
|
|
|
|
|
2026-06-19 01:23:32 +08:00
|
|
|
|
// Trend chart
|
|
|
|
|
|
const trendChartRef = ref<HTMLElement | null>(null)
|
|
|
|
|
|
let trendChart: echarts.ECharts | null = null
|
|
|
|
|
|
|
2026-06-18 10:47:52 +08:00
|
|
|
|
const latestException = computed<BugCollectExceptionInfo | null>(() => {
|
|
|
|
|
|
for (const e of detail.value?.events ?? []) {
|
|
|
|
|
|
if (e.exception?.stacktrace || e.exception?.value) return e.exception!
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
})
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
2026-06-18 10:47:52 +08:00
|
|
|
|
const latestDevice = computed<Record<string, unknown> | null>(() => {
|
|
|
|
|
|
for (const e of detail.value?.events ?? []) {
|
|
|
|
|
|
if (!e.device) continue
|
|
|
|
|
|
try {
|
|
|
|
|
|
return (typeof e.device === 'string' ? JSON.parse(e.device) : e.device) as Record<string, unknown>
|
|
|
|
|
|
} catch { /* ignore */ }
|
2026-06-17 10:24:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
return null
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-18 10:47:52 +08:00
|
|
|
|
function levelTag(level: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
|
|
|
|
|
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
|
|
|
|
|
fatal: 'danger', error: 'warning', warning: '', info: 'info',
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
2026-06-18 10:47:52 +08:00
|
|
|
|
return map[level?.toLowerCase()] ?? ''
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 10:47:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 18:30:01 +08:00
|
|
|
|
|
2026-06-18 10:47:52 +08:00
|
|
|
|
async function handleResolve() {
|
|
|
|
|
|
if (!detail.value) return
|
|
|
|
|
|
actionLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await bugCollectApi.resolveIssue(detail.value.id)
|
|
|
|
|
|
detail.value = { ...detail.value, status: 'resolved' }
|
|
|
|
|
|
ElMessage.success('已标记为已解决')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('操作失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
actionLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleReopen() {
|
|
|
|
|
|
if (!detail.value) return
|
|
|
|
|
|
actionLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await bugCollectApi.reopenIssue(detail.value.id)
|
|
|
|
|
|
detail.value = { ...detail.value, status: 'open' }
|
|
|
|
|
|
ElMessage.success('已重新打开')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('操作失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
actionLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleIgnore() {
|
|
|
|
|
|
if (!detail.value) return
|
|
|
|
|
|
actionLoading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await bugCollectApi.ignoreIssue(detail.value.id)
|
|
|
|
|
|
detail.value = { ...detail.value, status: 'ignored' }
|
|
|
|
|
|
ElMessage.success('已忽略')
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('操作失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
actionLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDelete() {
|
|
|
|
|
|
if (!detail.value) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm('确认删除此问题及其所有事件?此操作不可恢复。', '删除确认', {
|
|
|
|
|
|
type: 'warning',
|
|
|
|
|
|
confirmButtonText: '确认删除',
|
|
|
|
|
|
cancelButtonText: '取消',
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
await bugCollectApi.deleteIssue(detail.value.id)
|
|
|
|
|
|
ElMessage.success('已删除')
|
|
|
|
|
|
router.back()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
ElMessage.error('删除失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 18:30:01 +08:00
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
const id = route.params.id as string
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await bugCollectApi.issueDetail(id)
|
|
|
|
|
|
detail.value = res.data.data
|
2026-06-19 01:23:32 +08:00
|
|
|
|
await nextTick()
|
|
|
|
|
|
loadTrend()
|
2026-06-16 18:30:01 +08:00
|
|
|
|
} catch {
|
2026-06-18 10:47:52 +08:00
|
|
|
|
ElMessage.error('加载失败')
|
2026-06-16 18:30:01 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-06-19 01:23:32 +08:00
|
|
|
|
|
|
|
|
|
|
async function loadTrend() {
|
|
|
|
|
|
const id = route.params.id as string
|
|
|
|
|
|
if (!id || !trendChartRef.value) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await bugCollectApi.issueTrend(id)
|
|
|
|
|
|
const trend = res.data.data
|
|
|
|
|
|
if (!trend || !trendChartRef.value) return
|
|
|
|
|
|
|
|
|
|
|
|
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
|
|
|
|
|
|
trendChart.setOption({
|
|
|
|
|
|
tooltip: { trigger: 'axis' },
|
|
|
|
|
|
legend: { data: ['事件数', '影响用户'] },
|
|
|
|
|
|
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
|
|
|
|
|
xAxis: { type: 'category', data: trend.map(d => d.date) },
|
|
|
|
|
|
yAxis: [
|
|
|
|
|
|
{ type: 'value', name: '事件数', minInterval: 1 },
|
|
|
|
|
|
{ type: 'value', name: '影响用户', minInterval: 1 },
|
|
|
|
|
|
],
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '事件数',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
data: trend.map(d => d.count),
|
|
|
|
|
|
areaStyle: { opacity: 0.15 },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '影响用户',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
yAxisIndex: 1,
|
|
|
|
|
|
data: trend.map(d => d.affectedUsers),
|
|
|
|
|
|
areaStyle: { opacity: 0.15 },
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// trend loading failure is non-fatal
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleChartResize() {
|
|
|
|
|
|
trendChart?.resize()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.removeEventListener('resize', handleChartResize)
|
|
|
|
|
|
trendChart?.dispose()
|
|
|
|
|
|
})
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</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;
|
|
|
|
|
|
}
|
|
|
|
|
|
.props-json {
|
|
|
|
|
|
background: #f5f5f5;
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
max-height: 400px;
|
2026-06-18 10:47:52 +08:00
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
word-break: break-all;
|
2026-06-16 18:30:01 +08:00
|
|
|
|
}
|
2026-06-19 01:23:32 +08:00
|
|
|
|
.chart-container {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 260px;
|
|
|
|
|
|
}
|
2026-06-16 18:30:01 +08:00
|
|
|
|
</style>
|