feat: add desktop bug report collection
This commit is contained in:
@@ -33,6 +33,11 @@ import {
|
||||
type DesktopAppSettingKey,
|
||||
} from './app-settings'
|
||||
import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version'
|
||||
import {
|
||||
initDesktopBugReporter,
|
||||
submitManualDesktopBugReport,
|
||||
syncDesktopBugReporterSession,
|
||||
} from './bug-reporter'
|
||||
import {
|
||||
defaultDesktopUpdateChannel,
|
||||
startDesktopClientUpdate,
|
||||
@@ -131,6 +136,7 @@ function silencePackagedConsole(): void {
|
||||
}
|
||||
|
||||
silencePackagedConsole()
|
||||
initDesktopBugReporter()
|
||||
if (isDevelopmentRuntime) {
|
||||
installObservedGlobalFetch()
|
||||
}
|
||||
@@ -957,6 +963,17 @@ function registerBridgeHandlers(): void {
|
||||
ipcMain.handle('desktop:runtime-account-snapshot', (_event, accountId: string) =>
|
||||
captureRuntimeAccountSnapshot(accountId),
|
||||
)
|
||||
safeHandle(
|
||||
'desktop:submit-bug-report',
|
||||
async (
|
||||
_event,
|
||||
input: {
|
||||
title?: string
|
||||
description?: string
|
||||
severity?: string
|
||||
},
|
||||
) => submitManualDesktopBugReport(input ?? {}),
|
||||
)
|
||||
safeHandle('desktop:bind-publish-account', async (_event, platformId: string) => {
|
||||
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo
|
||||
noteRuntimeAccountBound(account)
|
||||
@@ -1052,6 +1069,7 @@ function registerBridgeHandlers(): void {
|
||||
'desktop:runtime-session-sync',
|
||||
(_event, session: DesktopRuntimeSessionSyncRequest | null) => {
|
||||
syncRuntimeSession(session)
|
||||
syncDesktopBugReporterSession(session)
|
||||
return null
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { appendFile, readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import type { DesktopRuntimeSessionSyncRequest } from '@geo/shared-types'
|
||||
import { crashReporter } from 'electron'
|
||||
import { app } from 'electron/main'
|
||||
|
||||
import { normalizeDesktopApiBaseURL } from '../shared/api-base-url'
|
||||
import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version'
|
||||
import { resolveDesktopDeviceInfo } from './device-id'
|
||||
import { createRuntimeSnapshot } from './runtime-snapshot'
|
||||
|
||||
const BUG_REPORT_LOG_FILE = 'desktop-bug-reporter.jsonl'
|
||||
const MAX_LOG_TAIL_BYTES = 96 * 1024
|
||||
const MAX_RUNTIME_SNAPSHOT_BYTES = 180 * 1024
|
||||
const CRASH_EXTRA_KEYS = [
|
||||
'app_version',
|
||||
'channel',
|
||||
'device_name',
|
||||
'platform',
|
||||
'cpu_arch',
|
||||
'client_id',
|
||||
'tenant_id',
|
||||
'workspace_id',
|
||||
'user_id',
|
||||
]
|
||||
|
||||
interface BugReporterSession {
|
||||
baseURL: string
|
||||
clientToken: string | null
|
||||
tenantID: number | null
|
||||
workspaceID: number | null
|
||||
userID: number | null
|
||||
clientID: string | null
|
||||
deviceName: string | null
|
||||
platform: string | null
|
||||
osArch: string | null
|
||||
appVersion: string | null
|
||||
channel: string | null
|
||||
}
|
||||
|
||||
export interface ManualDesktopBugReportInput {
|
||||
title?: string
|
||||
description?: string
|
||||
severity?: string
|
||||
}
|
||||
|
||||
let currentSession: BugReporterSession = {
|
||||
baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL),
|
||||
clientToken: null,
|
||||
tenantID: null,
|
||||
workspaceID: null,
|
||||
userID: null,
|
||||
clientID: null,
|
||||
deviceName: null,
|
||||
platform: process.platform,
|
||||
osArch: process.arch,
|
||||
appVersion: getDesktopAppVersion(),
|
||||
channel: getDesktopReleaseChannel(),
|
||||
}
|
||||
let crashReporterStarted = false
|
||||
|
||||
export function initDesktopBugReporter(): void {
|
||||
if (crashReporterStarted) {
|
||||
return
|
||||
}
|
||||
crashReporterStarted = true
|
||||
|
||||
const device = resolveDesktopDeviceInfo()
|
||||
currentSession = {
|
||||
...currentSession,
|
||||
deviceName: device.device_name,
|
||||
platform: device.os,
|
||||
osArch: device.cpu_arch,
|
||||
}
|
||||
|
||||
crashReporter.start({
|
||||
submitURL: buildBugReportURL(currentSession.baseURL, false),
|
||||
productName: 'ShengxinPush',
|
||||
uploadToServer: true,
|
||||
rateLimit: true,
|
||||
compress: true,
|
||||
globalExtra: buildStaticCrashReporterExtra(),
|
||||
})
|
||||
}
|
||||
|
||||
export function syncDesktopBugReporterSession(
|
||||
session: DesktopRuntimeSessionSyncRequest | null,
|
||||
): void {
|
||||
const client = session?.desktop_client ?? null
|
||||
currentSession = {
|
||||
...currentSession,
|
||||
baseURL: normalizeDesktopApiBaseURL(session?.api_base_url ?? process.env.DESKTOP_API_BASE_URL),
|
||||
clientToken: session?.client_token ?? null,
|
||||
tenantID: normalizePositiveNumber(client?.tenant_id),
|
||||
workspaceID: normalizePositiveNumber(client?.workspace_id),
|
||||
userID: normalizePositiveNumber(client?.user_id),
|
||||
clientID: client?.id ?? null,
|
||||
deviceName: client?.device_name ?? currentSession.deviceName,
|
||||
platform: client?.os ?? currentSession.platform,
|
||||
osArch: client?.cpu_arch ?? currentSession.osArch,
|
||||
appVersion: client?.client_version ?? getDesktopAppVersion(),
|
||||
channel: client?.channel ?? getDesktopReleaseChannel(),
|
||||
}
|
||||
updateCrashReporterParameters()
|
||||
}
|
||||
|
||||
export async function submitManualDesktopBugReport(
|
||||
input: ManualDesktopBugReportInput,
|
||||
): Promise<{ id: string; dump_object_key?: string | null }> {
|
||||
const title = String(input.title ?? '').trim()
|
||||
const description = String(input.description ?? '').trim()
|
||||
if (!title) {
|
||||
throw new Error('bug_report_title_required')
|
||||
}
|
||||
if (!description) {
|
||||
throw new Error('bug_report_description_required')
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
appendBaseFields(form, 'bug')
|
||||
form.set('title', limitText(title, 200))
|
||||
form.set('description', limitText(description, 4000))
|
||||
form.set('severity', normalizeSeverity(input.severity))
|
||||
form.set('runtime_snapshot', await safeRuntimeSnapshotJSON())
|
||||
form.set('app_log_tail', await readLogTail())
|
||||
|
||||
const url = buildBugReportURL(currentSession.baseURL, Boolean(currentSession.clientToken))
|
||||
const headers: Record<string, string> = {}
|
||||
if (currentSession.clientToken) {
|
||||
headers.Authorization = `Bearer ${currentSession.clientToken}`
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
})
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
code?: number
|
||||
message?: string
|
||||
detail?: string
|
||||
data?: { id?: string; dump_object_key?: string | null }
|
||||
} | null
|
||||
|
||||
if (!response.ok || payload?.code !== 0) {
|
||||
throw new Error(
|
||||
payload?.detail || payload?.message || `bug_report_upload_failed_${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
const id = payload.data?.id
|
||||
if (!id) {
|
||||
throw new Error('bug_report_upload_invalid_response')
|
||||
}
|
||||
await appendBugReporterLog({ action: 'manual_bug_report_uploaded', id })
|
||||
return {
|
||||
id,
|
||||
dump_object_key: payload.data?.dump_object_key ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildBugReportURL(baseURL: string, authed: boolean): string {
|
||||
const path = authed ? '/api/desktop/clients/bug-reports' : '/api/desktop/bug-reports'
|
||||
return new URL(path, normalizeDesktopApiBaseURL(baseURL)).toString()
|
||||
}
|
||||
|
||||
function appendBaseFields(form: FormData, reportType: 'crash' | 'bug'): void {
|
||||
const fields = buildCrashReporterExtra()
|
||||
form.set('report_type', reportType)
|
||||
form.set('occurred_at', new Date().toISOString())
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value) {
|
||||
form.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildCrashReporterExtra(): Record<string, string> {
|
||||
return {
|
||||
...buildStaticCrashReporterExtra(),
|
||||
client_id: limitExtra(currentSession.clientID ?? ''),
|
||||
tenant_id: limitExtra(currentSession.tenantID ? String(currentSession.tenantID) : ''),
|
||||
workspace_id: limitExtra(currentSession.workspaceID ? String(currentSession.workspaceID) : ''),
|
||||
user_id: limitExtra(currentSession.userID ? String(currentSession.userID) : ''),
|
||||
}
|
||||
}
|
||||
|
||||
function buildStaticCrashReporterExtra(): Record<string, string> {
|
||||
return {
|
||||
app_version: limitExtra(currentSession.appVersion ?? getDesktopAppVersion()),
|
||||
channel: limitExtra(currentSession.channel ?? getDesktopReleaseChannel()),
|
||||
device_name: limitExtra(currentSession.deviceName ?? ''),
|
||||
platform: limitExtra(currentSession.platform ?? process.platform),
|
||||
cpu_arch: limitExtra(currentSession.osArch ?? process.arch),
|
||||
}
|
||||
}
|
||||
|
||||
function updateCrashReporterParameters(): void {
|
||||
if (!crashReporterStarted) {
|
||||
return
|
||||
}
|
||||
for (const key of CRASH_EXTRA_KEYS) {
|
||||
crashReporter.removeExtraParameter(key)
|
||||
}
|
||||
for (const [key, value] of Object.entries(buildCrashReporterExtra())) {
|
||||
if (value) {
|
||||
crashReporter.addExtraParameter(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRuntimeSnapshotJSON(): Promise<string> {
|
||||
try {
|
||||
const raw = JSON.stringify(createRuntimeSnapshot())
|
||||
return raw.length > MAX_RUNTIME_SNAPSHOT_BYTES ? raw.slice(0, MAX_RUNTIME_SNAPSHOT_BYTES) : raw
|
||||
} catch {
|
||||
return '{}'
|
||||
}
|
||||
}
|
||||
|
||||
async function readLogTail(): Promise<string> {
|
||||
const candidates = resolveLogCandidates()
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = statSync(candidate)
|
||||
if (!stat.isFile()) {
|
||||
continue
|
||||
}
|
||||
const content = await readFile(candidate, 'utf8')
|
||||
return content.slice(-MAX_LOG_TAIL_BYTES)
|
||||
} catch {
|
||||
// Try the next known log file.
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveLogCandidates(): string[] {
|
||||
const dirs = [app.getPath('logs'), app.getPath('userData')]
|
||||
const candidates: string[] = []
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower.endsWith('.log') || lower.endsWith('.jsonl')) {
|
||||
candidates.push(join(dir, name))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Logs may not exist in dev or first-run installs.
|
||||
}
|
||||
}
|
||||
return candidates.sort((left, right) => {
|
||||
try {
|
||||
return statSync(right).mtimeMs - statSync(left).mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSeverity(value: string | undefined): string {
|
||||
switch (
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
) {
|
||||
case 'low':
|
||||
case 'medium':
|
||||
case 'high':
|
||||
case 'critical':
|
||||
return String(value).trim().toLowerCase()
|
||||
default:
|
||||
return 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePositiveNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function limitExtra(value: string): string {
|
||||
return limitText(value, 120)
|
||||
}
|
||||
|
||||
function limitText(value: string, max: number): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
const runes = [...trimmed]
|
||||
return runes.length > max ? runes.slice(0, max).join('') : trimmed
|
||||
}
|
||||
|
||||
async function appendBugReporterLog(event: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
await appendFile(
|
||||
join(app.getPath('userData'), BUG_REPORT_LOG_FILE),
|
||||
`${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`,
|
||||
'utf8',
|
||||
)
|
||||
} catch {
|
||||
// Best-effort local breadcrumbs only.
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
CreatePublishJobResponse,
|
||||
DesktopAccountInfo,
|
||||
DesktopBugReportSubmitRequest,
|
||||
DesktopBugReportSubmitResponse,
|
||||
DesktopClientReleaseCheckResponse,
|
||||
DesktopClientRotateResponse,
|
||||
DesktopClientUpdateProgressEvent,
|
||||
@@ -145,6 +147,11 @@ const desktopBridge = {
|
||||
ipcRenderer.invoke('desktop:runtime-account-snapshot', accountId) as Promise<
|
||||
DesktopRuntimeSnapshot['accounts'][number] | null
|
||||
>,
|
||||
submitBugReport: (payload: DesktopBugReportSubmitRequest) =>
|
||||
ipcRenderer.invoke(
|
||||
'desktop:submit-bug-report',
|
||||
payload,
|
||||
) as Promise<DesktopBugReportSubmitResponse>,
|
||||
bindPublishAccount: (platformId: string) =>
|
||||
ipcRenderer.invoke('desktop:bind-publish-account', platformId) as Promise<DesktopAccountInfo>,
|
||||
refreshRuntimeAccounts: () =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BugOutlined,
|
||||
DownloadOutlined,
|
||||
LinkOutlined,
|
||||
LogoutOutlined,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
SendOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
|
||||
@@ -35,6 +37,14 @@ const updateModalOpen = ref(false)
|
||||
const updateModalVersion = ref('')
|
||||
const updateModalForce = ref(false)
|
||||
const updateModalStarted = ref(false)
|
||||
const bugReportOpen = ref(false)
|
||||
const bugReportSubmitting = ref(false)
|
||||
type BugReportSeverity = 'low' | 'medium' | 'high' | 'critical'
|
||||
const bugReportForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
severity: 'medium' as BugReportSeverity,
|
||||
})
|
||||
|
||||
const accountAwareRoutes = new Set(['/media-platforms', '/ai-platforms'])
|
||||
|
||||
@@ -136,7 +146,10 @@ const updateProgressBarPercent = computed(() => {
|
||||
if (updateProgress.value?.stage === 'checking') {
|
||||
return 8
|
||||
}
|
||||
if (updateProgress.value?.stage === 'downloaded' || updateProgress.value?.stage === 'installing') {
|
||||
if (
|
||||
updateProgress.value?.stage === 'downloaded' ||
|
||||
updateProgress.value?.stage === 'installing'
|
||||
) {
|
||||
return 100
|
||||
}
|
||||
return updateProgressPercent.value ?? 8
|
||||
@@ -173,6 +186,37 @@ function openSettingsWindow() {
|
||||
void window.desktopBridge.app.openSettingsWindow()
|
||||
}
|
||||
|
||||
function openBugReportDialog() {
|
||||
bugReportForm.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
severity: 'medium' as BugReportSeverity,
|
||||
}
|
||||
bugReportOpen.value = true
|
||||
}
|
||||
|
||||
async function submitBugReport() {
|
||||
const title = bugReportForm.value.title.trim()
|
||||
const description = bugReportForm.value.description.trim()
|
||||
if (!title || !description) {
|
||||
return
|
||||
}
|
||||
bugReportSubmitting.value = true
|
||||
try {
|
||||
const result = await window.desktopBridge.app.submitBugReport({
|
||||
title,
|
||||
description,
|
||||
severity: bugReportForm.value.severity,
|
||||
})
|
||||
bugReportOpen.value = false
|
||||
message.success(`反馈已提交:${result.id}`)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '反馈提交失败')
|
||||
} finally {
|
||||
bugReportSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startClientReleaseUpdate() {
|
||||
updateModalStarted.value = true
|
||||
const updated = await startClientUpdate()
|
||||
@@ -275,6 +319,11 @@ watch(
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<div class="action-cluster">
|
||||
<a-tooltip title="反馈问题" placement="top">
|
||||
<button type="button" class="footer-icon-btn" @click="openBugReportDialog">
|
||||
<BugOutlined />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="设置" placement="top">
|
||||
<button type="button" class="footer-icon-btn" @click="openSettingsWindow">
|
||||
<SettingOutlined />
|
||||
@@ -388,6 +437,47 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="bugReportOpen"
|
||||
title="反馈客户端问题"
|
||||
:confirm-loading="bugReportSubmitting"
|
||||
ok-text="提交"
|
||||
cancel-text="取消"
|
||||
:ok-button-props="{
|
||||
disabled: !bugReportForm.title.trim() || !bugReportForm.description.trim(),
|
||||
}"
|
||||
@ok="submitBugReport"
|
||||
>
|
||||
<a-form layout="vertical" :model="bugReportForm">
|
||||
<a-form-item label="标题" required>
|
||||
<a-input
|
||||
v-model:value="bugReportForm.title"
|
||||
placeholder="例如:发布时客户端卡住"
|
||||
:maxlength="120"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="严重级别">
|
||||
<a-segmented
|
||||
v-model:value="bugReportForm.severity"
|
||||
:options="[
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '高', value: 'high' },
|
||||
{ label: '致命', value: 'critical' },
|
||||
]"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="现象描述" required>
|
||||
<a-textarea
|
||||
v-model:value="bugReportForm.description"
|
||||
placeholder="请描述刚才做了什么、看到了什么异常"
|
||||
:maxlength="2000"
|
||||
:rows="5"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+6
-1
@@ -1,10 +1,12 @@
|
||||
import type {
|
||||
CreatePublishJobResponse,
|
||||
DesktopAccountInfo,
|
||||
DesktopBugReportSubmitRequest,
|
||||
DesktopBugReportSubmitResponse,
|
||||
DesktopClientReleaseCheckResponse,
|
||||
DesktopClientRotateResponse,
|
||||
DesktopClientUpdateProgressEvent,
|
||||
DesktopClientUpdateResult,
|
||||
DesktopClientRotateResponse,
|
||||
DesktopPublishTaskListResponse,
|
||||
DesktopRuntimeSessionSyncRequest,
|
||||
ListDesktopPublishTasksParams,
|
||||
@@ -61,6 +63,9 @@ declare global {
|
||||
runtimeAccountSnapshot(
|
||||
accountId: string,
|
||||
): Promise<DesktopRuntimeSnapshot['accounts'][number] | null>
|
||||
submitBugReport(
|
||||
payload: DesktopBugReportSubmitRequest,
|
||||
): Promise<DesktopBugReportSubmitResponse>
|
||||
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>
|
||||
refreshRuntimeAccounts(): Promise<null>
|
||||
probeRuntimeAccount(
|
||||
|
||||
@@ -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: '内容安全',
|
||||
|
||||
@@ -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 ?? '—'
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user