feat: add desktop bug report collection

This commit is contained in:
2026-05-31 21:55:29 +08:00
parent 0a9dcec70f
commit e490a267ff
27 changed files with 2938 additions and 3 deletions
+7
View File
@@ -49,6 +49,7 @@
import {
AuditOutlined,
BookOutlined,
BugOutlined,
ClockCircleOutlined,
CloudDownloadOutlined,
ControlOutlined,
@@ -88,6 +89,7 @@ const menuLeaves: MenuLeaf[] = [
{ key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' },
{ key: '/object-storage', label: '对象存储', path: '/object-storage' },
{ key: '/desktop-client/releases', label: '客户端版本', path: '/desktop-client/releases' },
{ key: '/desktop-client/bug-reports', label: '客户端 Bug', path: '/desktop-client/bug-reports' },
{ key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' },
{ key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' },
{ key: '/compliance/manual-reviews', label: '人工审阅', path: '/compliance/manual-reviews' },
@@ -126,6 +128,11 @@ const menuItems = computed<ItemType[]>(() => [
label: '客户端版本',
icon: () => h(CloudDownloadOutlined),
},
{
key: '/desktop-client/bug-reports',
label: '客户端 Bug',
icon: () => h(BugOutlined),
},
{
key: 'compliance',
label: '内容安全',
+124
View File
@@ -0,0 +1,124 @@
import { http } from '@/lib/http'
export type DesktopBugReportType = 'crash' | 'bug'
export type DesktopBugReportStatus = 'new' | 'triaged' | 'in_progress' | 'resolved' | 'ignored'
export type DesktopBugReportSeverity = 'low' | 'medium' | 'high' | 'critical'
export interface DesktopBugReport {
id: string
report_type: DesktopBugReportType
status: DesktopBugReportStatus
severity: DesktopBugReportSeverity
tenant_id?: number | null
tenant_name?: string | null
workspace_id?: number | null
workspace_name?: string | null
user_id?: number | null
user_name?: string | null
user_phone?: string | null
user_email?: string | null
desktop_client_id?: string | null
crash_guid?: string | null
crash_report_id?: string | null
process_type?: string | null
electron_version?: string | null
app_version?: string | null
product_name?: string | null
platform?: string | null
os_arch?: string | null
device_name?: string | null
title: string
description?: string | null
dump_object_key?: string | null
dump_file_name?: string | null
dump_size_bytes?: number | null
dump_sha256?: string | null
form_fields: Record<string, unknown>
runtime_snapshot: Record<string, unknown>
app_log_tail?: string | null
resolution_note?: string | null
uploader_ip?: string | null
user_agent?: string | null
occurred_at: string
resolved_at?: string | null
created_at: string
updated_at: string
}
export interface DesktopBugReportList {
items: DesktopBugReport[]
total: number
page: number
size: number
}
export interface DesktopBugReportUpdatePayload {
status: DesktopBugReportStatus
severity: DesktopBugReportSeverity
resolution_note?: string | null
}
export interface DesktopBugReportDumpURL {
download_url: string
}
export const desktopBugReportStatusOptions = [
{ label: '新建', value: 'new' },
{ label: '已分诊', value: 'triaged' },
{ label: '处理中', value: 'in_progress' },
{ label: '已解决', value: 'resolved' },
{ label: '忽略', value: 'ignored' },
]
export const desktopBugReportSeverityOptions = [
{ label: '低', value: 'low' },
{ label: '中', value: 'medium' },
{ label: '高', value: 'high' },
{ label: '致命', value: 'critical' },
]
export const desktopBugReportTypeOptions = [
{ label: '崩溃', value: 'crash' },
{ label: '反馈', value: 'bug' },
]
export const desktopBugReportApi = {
list(params: {
keyword?: string
status?: string
severity?: string
report_type?: string
platform?: string
process_type?: string
tenant_id?: number
client_id?: string
page?: number
size?: number
}) {
return http.get<DesktopBugReportList>('/desktop-client/bug-reports', params)
},
get(id: string) {
return http.get<DesktopBugReport>(`/desktop-client/bug-reports/${id}`)
},
update(id: string, payload: DesktopBugReportUpdatePayload) {
return http.patch<DesktopBugReport, DesktopBugReportUpdatePayload>(
`/desktop-client/bug-reports/${id}`,
payload,
)
},
resolveDumpURL(id: string) {
return http.get<DesktopBugReportDumpURL>(`/desktop-client/bug-reports/${id}/dump-url`)
},
}
export function desktopBugReportStatusLabel(value?: string | null): string {
return desktopBugReportStatusOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
export function desktopBugReportSeverityLabel(value?: string | null): string {
return desktopBugReportSeverityOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
export function desktopBugReportTypeLabel(value?: string | null): string {
return desktopBugReportTypeOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
+6
View File
@@ -79,6 +79,12 @@ export const router = createRouter({
component: () => import('@/views/DesktopClientReleasesView.vue'),
meta: { title: '客户端版本' },
},
{
path: 'desktop-client/bug-reports',
name: 'desktop-client-bug-reports',
component: () => import('@/views/DesktopBugReportsView.vue'),
meta: { title: '客户端 Bug' },
},
{
path: 'compliance/policy',
name: 'compliance-policy',
@@ -0,0 +1,619 @@
<template>
<div class="bug-page">
<div class="bug-header">
<div>
<h2 class="ops-page-title bug-title">客户端 Bug</h2>
<div class="bug-summary">
<span>全部 {{ total }}</span>
<span>新建 {{ pageNewCount }}</span>
<span>致命 {{ pageCriticalCount }}</span>
</div>
</div>
<a-button @click="reload">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<div class="ops-card bug-card">
<div class="ops-toolbar bug-toolbar">
<a-input
v-model:value="filter.keyword"
class="bug-search"
placeholder="搜索标题 / 用户 / 设备 / crash guid / Object Key"
allow-clear
@press-enter="resetAndReload"
@change="onKeywordChange"
/>
<a-select
v-model:value="filter.report_type"
class="bug-select"
:options="desktopBugReportTypeOptions"
placeholder="类型"
allow-clear
@change="resetAndReload"
/>
<a-select
v-model:value="filter.status"
class="bug-select"
:options="desktopBugReportStatusOptions"
placeholder="状态"
allow-clear
@change="resetAndReload"
/>
<a-select
v-model:value="filter.severity"
class="bug-select"
:options="desktopBugReportSeverityOptions"
placeholder="级别"
allow-clear
@change="resetAndReload"
/>
<a-select
v-model:value="filter.platform"
class="bug-select"
:options="platformOptions"
placeholder="平台"
allow-clear
@change="resetAndReload"
/>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 1280 }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="bug-title-cell">
<strong>{{ record.title }}</strong>
<span>{{ record.id }}</span>
</div>
</template>
<template v-else-if="column.key === 'scope'">
<div class="scope-cell">
<strong>{{ record.tenant_name || `租户 #${record.tenant_id || '—'}` }}</strong>
<span>{{ userLabel(record) }}</span>
</div>
</template>
<template v-else-if="column.key === 'runtime'">
<div class="runtime-cell">
<strong>{{ record.device_name || '未知设备' }}</strong>
<span>
{{ platformLabel(record.platform) }} · {{ record.process_type || 'unknown' }} · v{{
record.app_version || '—'
}}
</span>
</div>
</template>
<template v-else-if="column.key === 'labels'">
<a-space size="small">
<a-tag :color="record.report_type === 'crash' ? 'red' : 'blue'">
{{ desktopBugReportTypeLabel(record.report_type) }}
</a-tag>
<a-tag :color="severityColor(record.severity)">
{{ desktopBugReportSeverityLabel(record.severity) }}
</a-tag>
<a-tag :color="statusColor(record.status)">
{{ desktopBugReportStatusLabel(record.status) }}
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'dump'">
<div v-if="record.dump_object_key" class="dump-cell">
<strong>{{ record.dump_file_name || 'minidump.dmp' }}</strong>
<span>{{ formatBytes(record.dump_size_bytes) }}</span>
</div>
<span v-else class="muted"> dump</span>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row" style="justify-content: flex-end">
<a-tooltip title="详情">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-edit"
@click="openDetail(record)"
>
<EyeOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="复制 dump 下载地址">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn"
:disabled="!record.dump_object_key"
@click="copyDumpURL(record)"
>
<DownloadOutlined />
</a-button>
</a-tooltip>
</div>
</template>
</template>
</a-table>
</div>
<a-drawer
v-model:open="detailOpen"
title="客户端 Bug 详情"
width="760"
:destroy-on-close="true"
>
<template v-if="selected">
<div class="detail-head">
<div>
<h3>{{ selected.title }}</h3>
<p>{{ selected.id }}</p>
</div>
<a-space>
<a-tag :color="severityColor(selected.severity)">
{{ desktopBugReportSeverityLabel(selected.severity) }}
</a-tag>
<a-tag :color="statusColor(selected.status)">
{{ desktopBugReportStatusLabel(selected.status) }}
</a-tag>
</a-space>
</div>
<a-form layout="vertical" class="state-form">
<div class="form-grid">
<a-form-item label="状态">
<a-select v-model:value="stateForm.status" :options="desktopBugReportStatusOptions" />
</a-form-item>
<a-form-item label="级别">
<a-select
v-model:value="stateForm.severity"
:options="desktopBugReportSeverityOptions"
/>
</a-form-item>
</div>
<a-form-item label="处理备注">
<a-textarea v-model:value="stateForm.resolution_note" :rows="3" />
</a-form-item>
<a-button type="primary" :loading="saving" @click="saveState">保存处理状态</a-button>
</a-form>
<a-descriptions bordered size="small" :column="2" class="detail-descriptions">
<a-descriptions-item label="类型">
{{ desktopBugReportTypeLabel(selected.report_type) }}
</a-descriptions-item>
<a-descriptions-item label="进程">{{ selected.process_type || '—' }}</a-descriptions-item>
<a-descriptions-item label="租户">
{{ selected.tenant_name || selected.tenant_id || '—' }}
</a-descriptions-item>
<a-descriptions-item label="用户">{{ userLabel(selected) }}</a-descriptions-item>
<a-descriptions-item label="设备">{{ selected.device_name || '—' }}</a-descriptions-item>
<a-descriptions-item label="客户端 ID">
{{ selected.desktop_client_id || '—' }}
</a-descriptions-item>
<a-descriptions-item label="平台">
{{ platformLabel(selected.platform) }} / {{ selected.os_arch || '—' }}
</a-descriptions-item>
<a-descriptions-item label="版本">
App {{ selected.app_version || '—' }} · Electron {{ selected.electron_version || '—' }}
</a-descriptions-item>
<a-descriptions-item label="Crash GUID" :span="2">
{{ selected.crash_guid || '—' }}
</a-descriptions-item>
<a-descriptions-item label="OSS Key" :span="2">
<span class="mono">{{ selected.dump_object_key || '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="发生时间">
{{ formatDate(selected.occurred_at) }}
</a-descriptions-item>
<a-descriptions-item label="上报时间">
{{ formatDate(selected.created_at) }}
</a-descriptions-item>
</a-descriptions>
<div class="detail-section">
<h4>问题描述</h4>
<p class="detail-text">{{ selected.description || '—' }}</p>
</div>
<div class="detail-section">
<h4>运行快照</h4>
<pre>{{ prettyJSON(selected.runtime_snapshot) }}</pre>
</div>
<div class="detail-section">
<h4>上报字段</h4>
<pre>{{ prettyJSON(selected.form_fields) }}</pre>
</div>
<div class="detail-section">
<h4>日志尾巴</h4>
<pre>{{ selected.app_log_tail || '—' }}</pre>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { DownloadOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import { copyTextToClipboard } from '@/lib/clipboard'
import {
desktopBugReportApi,
desktopBugReportSeverityLabel,
desktopBugReportSeverityOptions,
desktopBugReportStatusLabel,
desktopBugReportStatusOptions,
desktopBugReportTypeLabel,
desktopBugReportTypeOptions,
type DesktopBugReport,
type DesktopBugReportSeverity,
type DesktopBugReportStatus,
} from '@/lib/desktop-bug-reports'
import { showOpsError } from '@/lib/errors'
const rows = ref<DesktopBugReport[]>([])
const total = ref(0)
const loading = ref(false)
const saving = ref(false)
const detailOpen = ref(false)
const selected = ref<DesktopBugReport | null>(null)
const filter = reactive({
keyword: '',
report_type: undefined as string | undefined,
status: undefined as string | undefined,
severity: undefined as string | undefined,
platform: undefined as string | undefined,
page: 1,
size: 20,
})
const stateForm = reactive({
status: 'new' as DesktopBugReportStatus,
severity: 'medium' as DesktopBugReportSeverity,
resolution_note: '',
})
let keywordTimer: ReturnType<typeof setTimeout> | null = null
const columns = [
{ title: '问题', key: 'title', width: 280 },
{ title: '归属', key: 'scope', width: 220 },
{ title: '运行环境', key: 'runtime', width: 260 },
{ title: '标签', key: 'labels', width: 220 },
{ title: 'Dump', key: 'dump', width: 220 },
{ title: '上报时间', key: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 120, fixed: 'right' },
]
const platformOptions = [
{ label: 'macOS', value: 'darwin' },
{ label: 'Windows', value: 'win32' },
{ label: 'Linux', value: 'linux' },
]
const pageNewCount = computed(() => rows.value.filter((item) => item.status === 'new').length)
const pageCriticalCount = computed(
() => rows.value.filter((item) => item.severity === 'critical').length,
)
const pagination = computed<TablePaginationConfig>(() => ({
current: filter.page,
pageSize: filter.size,
total: total.value,
showSizeChanger: true,
showTotal: (value) => `${value}`,
}))
async function reload() {
loading.value = true
try {
const result = await desktopBugReportApi.list({
keyword: filter.keyword || undefined,
report_type: filter.report_type,
status: filter.status,
severity: filter.severity,
platform: filter.platform,
page: filter.page,
size: filter.size,
})
rows.value = result.items
total.value = result.total
} catch (error) {
showOpsError(error, '客户端 Bug 加载失败')
} finally {
loading.value = false
}
}
function resetAndReload() {
filter.page = 1
void reload()
}
function onKeywordChange() {
if (keywordTimer) {
clearTimeout(keywordTimer)
}
keywordTimer = setTimeout(resetAndReload, 300)
}
function onTableChange(p: TablePaginationConfig) {
filter.page = p.current ?? 1
filter.size = p.pageSize ?? 20
void reload()
}
async function openDetail(row: DesktopBugReport) {
detailOpen.value = true
selected.value = row
assignStateForm(row)
try {
const detail = await desktopBugReportApi.get(row.id)
selected.value = detail
assignStateForm(detail)
} catch (error) {
showOpsError(error, '客户端 Bug 详情加载失败')
}
}
function assignStateForm(row: DesktopBugReport) {
stateForm.status = row.status
stateForm.severity = row.severity
stateForm.resolution_note = row.resolution_note ?? ''
}
async function saveState() {
if (!selected.value) {
return
}
saving.value = true
try {
const updated = await desktopBugReportApi.update(selected.value.id, {
status: stateForm.status,
severity: stateForm.severity,
resolution_note: stateForm.resolution_note || null,
})
selected.value = updated
rows.value = rows.value.map((item) => (item.id === updated.id ? updated : item))
message.success('处理状态已保存')
} catch (error) {
showOpsError(error, '处理状态保存失败')
} finally {
saving.value = false
}
}
async function copyDumpURL(row: DesktopBugReport) {
try {
const result = await desktopBugReportApi.resolveDumpURL(row.id)
await copyTextToClipboard(result.download_url)
message.success('dump 下载地址已复制')
} catch (error) {
showOpsError(error, 'dump 下载地址生成失败')
}
}
function userLabel(row: DesktopBugReport): string {
return (
row.user_name ||
row.user_phone ||
row.user_email ||
(row.user_id ? `用户 #${row.user_id}` : '—')
)
}
function platformLabel(value?: string | null): string {
return platformOptions.find((item) => item.value === value)?.label ?? value ?? '—'
}
function severityColor(value: string): string {
const map: Record<string, string> = {
low: 'default',
medium: 'gold',
high: 'orange',
critical: 'red',
}
return map[value] ?? 'default'
}
function statusColor(value: string): string {
const map: Record<string, string> = {
new: 'red',
triaged: 'blue',
in_progress: 'purple',
resolved: 'green',
ignored: 'default',
}
return map[value] ?? 'default'
}
function formatBytes(value?: number | null): string {
if (!value || value <= 0) {
return '—'
}
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function formatDate(value?: string | null): string {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
function prettyJSON(value: unknown): string {
try {
return JSON.stringify(value ?? {}, null, 2)
} catch {
return '{}'
}
}
onMounted(reload)
</script>
<style scoped>
.bug-page {
min-width: 0;
}
.bug-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.bug-title.ops-page-title {
margin-bottom: 8px;
}
.bug-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 600;
}
.bug-summary span {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.bug-toolbar {
align-items: stretch;
}
.bug-search {
width: min(420px, 100%);
}
.bug-select {
width: 132px;
}
.bug-title-cell,
.scope-cell,
.runtime-cell,
.dump-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.bug-title-cell strong,
.scope-cell strong,
.runtime-cell strong,
.dump-cell strong {
color: #0f172a;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bug-title-cell span,
.scope-cell span,
.runtime-cell span,
.dump-cell span,
.muted {
color: #64748b;
font-size: 12px;
}
.detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.detail-head h3 {
margin: 0;
color: #0f172a;
font-size: 20px;
}
.detail-head p {
margin: 6px 0 0;
color: #64748b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.state-form {
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
margin-bottom: 20px;
background: #f8fafc;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-descriptions {
margin-bottom: 20px;
}
.detail-section {
margin-top: 20px;
}
.detail-section h4 {
margin: 0 0 8px;
color: #0f172a;
}
.detail-text {
margin: 0;
color: #334155;
white-space: pre-wrap;
line-height: 1.7;
}
pre {
max-height: 280px;
overflow: auto;
padding: 12px;
margin: 0;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.6;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
</style>