fix(bugcollect): 修复问题详情页字段映射,新增设备信息/状态管理

- 修复 level/release 字段未显示(原引用了废弃的 type/appVersion)
- 新增设备信息卡片(解析 device JSON 展示型号/系统/内存等)
- 新增状态管理操作栏(标记已解决/忽略/重新打开/删除)
- 修复事件列表使用 exception.value/stacktrace(新格式)
- 新增 reopenIssue API 方法

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-18 10:47:52 +08:00
父节点 a58d25fc24
当前提交 66414f7987
共有 2 个文件被更改,包括 172 次插入34 次删除

查看文件

@ -147,6 +147,10 @@ export const bugCollectApi = {
return client.put<{ data: void }>(`/bugcollect/v1/issues/${id}/ignore`)
},
reopenIssue(id: string | number) {
return client.put<{ data: void }>(`/bugcollect/v1/issues/${id}/reopen`)
},
assignIssue(id: string | number, assignee: string) {
return client.put<{ data: void }>(`/bugcollect/v1/issues/${id}/assign`, { assignee })
},

查看文件

@ -1,22 +1,42 @@
<template>
<div v-if="detail">
<el-page-header @back="$router.back()" :content="detail.title" style="margin-bottom: 24px" />
<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>
<!-- Basic Info -->
<el-card style="margin-bottom: 16px">
<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 label="级别">
<el-tag size="small" :type="levelTag(detail.level)">{{ detail.level ?? '-' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="平台">{{ detail.platform }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ detail.platform ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag size="small" :type="detail.isResolved ? 'success' : 'danger'">
{{ detail.isResolved ? '已解决' : '未解决' }}
</el-tag>
<el-tag size="small" :type="statusTag(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="应用版本">{{ detail.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ detail.release ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="总次数">{{ detail.count }}</el-descriptions-item>
<el-descriptions-item label="影响用户数">{{ detail.affectedUsers ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ detail.assignee ?? '未分配' }}</el-descriptions-item>
<el-descriptions-item label="指纹">
<span style="font-family:monospace;font-size:12px">{{ detail.fingerprint ?? '-' }}</span>
</el-descriptions-item>
@ -25,30 +45,59 @@
</el-descriptions>
</el-card>
<!-- Stack Trace (from most recent event) -->
<el-card v-if="latestStack" style="margin-bottom: 16px">
<template #header>Stack Trace</template>
<pre class="stack-trace">{{ latestStack }}</pre>
<!-- 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>
</el-card>
<!-- Recent Events -->
<el-card>
<template #header>最近崩溃事件</template>
<el-table :data="detail.events ?? []" border stripe>
<el-table-column prop="userId" label="用户 ID" width="180" show-overflow-tooltip />
<el-table-column label="用户 / 设备 ID" width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.userId ?? '-' }}</template>
</el-table-column>
<el-table-column prop="platform" label="平台" width="100" />
<el-table-column prop="appVersion" label="版本" width="110" />
<el-table-column prop="message" label="错误信息" min-width="240" show-overflow-tooltip />
<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>
<el-table-column prop="createdAt" label="时间" width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="堆栈" width="90" align="center">
<template #default="{ row }">
<el-popover v-if="row.stack" trigger="click" :width="600">
<el-popover v-if="row.exception?.stacktrace" trigger="click" :width="640">
<template #reference>
<el-button link type="primary" size="small">查看</el-button>
</template>
<pre class="props-json">{{ row.stackSymbolicated || row.stack }}</pre>
<pre class="props-json">{{ row.exception?.stackSymbolicated || row.exception?.stacktrace }}</pre>
</el-popover>
<span v-else style="color:#ccc">-</span>
</template>
@ -57,42 +106,124 @@
</el-card>
</div>
<div v-else v-loading="loading" style="min-height: 300px" />
<div v-else v-loading="loading" style="min-height:300px" />
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { bugCollectApi, type BugCollectIssueDetail } from '@/api/bugcollect'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bugCollectApi, type BugCollectIssueDetail, type BugCollectExceptionInfo } from '@/api/bugcollect'
const route = useRoute()
const router = useRouter()
const detail = ref<BugCollectIssueDetail | null>(null)
const loading = ref(false)
const actionLoading = ref(false)
const latestStack = computed(() => {
const events = detail.value?.events ?? []
for (const e of events) {
const s = e.stackSymbolicated || e.stack
if (s) return s
const latestException = computed<BugCollectExceptionInfo | null>(() => {
for (const e of detail.value?.events ?? []) {
if (e.exception?.stacktrace || e.exception?.value) return e.exception!
}
return null
})
function issueTypeTag(type: string) {
const map: Record<string, string> = {
CRASH: 'danger',
ERROR: 'warning',
ANR: 'danger',
WARNING: '',
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 */ }
}
return (map[type] ?? '') as '' | 'success' | 'warning' | 'info' | 'danger'
return null
})
function levelTag(level: string): '' | 'success' | 'warning' | 'info' | 'danger' {
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
fatal: 'danger', error: 'warning', warning: '', info: 'info',
}
return map[level?.toLowerCase()] ?? ''
}
function formatTime(ts: string) {
function statusTag(status: string): '' | 'success' | 'warning' | 'info' | 'danger' {
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
open: 'danger', resolved: 'success', ignored: 'info',
}
return map[status] ?? ''
}
function statusLabel(status: string): string {
const map: Record<string, string> = { open: '未解决', resolved: '已解决', ignored: '已忽略' }
return map[status] ?? status
}
function formatTime(ts: string): string {
if (!ts) return '-'
return new Date(ts).toLocaleString('zh-CN')
}
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('删除失败')
}
}
onMounted(async () => {
const id = route.params.id as string
loading.value = true
@ -100,6 +231,7 @@ onMounted(async () => {
const res = await bugCollectApi.issueDetail(id)
detail.value = res.data.data
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
@ -125,5 +257,7 @@ onMounted(async () => {
font-size: 12px;
overflow-x: auto;
max-height: 400px;
white-space: pre-wrap;
word-break: break-all;
}
</style>