Files
Xboard/admin-frontend/src/views/dashboard/QueueFailedJobsDialog.vue
T
yinjianm 16203b14f6 feat(admin-frontend): 新增系统与订阅管理后台页面
扩展管理端侧边栏与路由,新增系统配置真实页面、订阅套餐
管理页、节点管理页及多个结构化占位页

补齐前端 API、类型与工具层,并增强仪表盘刷新、趋势切换、
失败作业详情与流量排行 limit 联动能力

同步后端 traffic rank limit 支持与知识库归档、设计约束、
验证配置及视觉验收产物
2026-04-24 15:32:09 +08:00

465 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getHorizonFailedJobs } from '@/api/admin'
import type { AdminQueueFailedJob } from '@/types/api'
import { formatCompactNumber, formatDateTime } from '@/utils/dashboard'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
type LooseRecord = Record<string, unknown>
const loading = ref(false)
const records = ref<AdminQueueFailedJob[]>([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(10)
const latestFailedJob = computed(() => records.value[0] ?? null)
const summaryCards = computed(() => [
{
label: '失败总数',
value: formatCompactNumber(total.value),
detail: `当前页 ${records.value.length}`,
},
{
label: '最近失败时间',
value: latestFailedJob.value ? formatFailedAt(latestFailedJob.value) : 'N/A',
detail: '按最新失败时间倒序展示',
},
{
label: '最近失败队列',
value: latestFailedJob.value ? getQueueName(latestFailedJob.value) : 'N/A',
detail: latestFailedJob.value ? getJobName(latestFailedJob.value) : '暂无失败作业',
},
])
function isLooseRecord(value: unknown): value is LooseRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function getByPath(source: LooseRecord | null, path: string): unknown {
if (!source) return undefined
return path.split('.').reduce<unknown>((current, segment) => {
if (!isLooseRecord(current)) return undefined
return current[segment]
}, source)
}
function firstText(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
}
return null
}
function getPayload(record: AdminQueueFailedJob): LooseRecord | null {
if (isLooseRecord(record.payload)) {
return record.payload
}
if (typeof record.payload === 'string' && record.payload.trim()) {
try {
const parsed = JSON.parse(record.payload)
return isLooseRecord(parsed) ? parsed : null
} catch {
return null
}
}
return null
}
function getIdentifier(record: AdminQueueFailedJob): string {
return firstText(record.id, record.uuid) ?? 'unknown'
}
function getJobName(record: AdminQueueFailedJob): string {
const payload = getPayload(record)
return firstText(
record.name,
getByPath(payload, 'displayName'),
getByPath(payload, 'data.commandName'),
getByPath(payload, 'job'),
record.uuid,
record.id,
) ?? '未知任务'
}
function getQueueName(record: AdminQueueFailedJob): string {
const payload = getPayload(record)
return firstText(
record.queue,
getByPath(payload, 'queue'),
record.connection,
) ?? 'N/A'
}
function getFailureTime(record: AdminQueueFailedJob): number | string | null {
const payload = getPayload(record)
return (
firstText(
record.failed_at,
record['failedAt'],
getByPath(payload, 'failed_at'),
record['completed_at'],
record['completedAt'],
) ?? null
)
}
function formatFailedAt(record: AdminQueueFailedJob): string {
return formatDateTime(getFailureTime(record))
}
function getErrorMessage(record: AdminQueueFailedJob): string {
const payload = getPayload(record)
return firstText(
record.exception,
record['message'],
getByPath(payload, 'exception'),
getByPath(payload, 'message'),
) ?? '暂无错误详情'
}
function getErrorSummary(record: AdminQueueFailedJob): string {
const rawMessage = getErrorMessage(record)
const lines = rawMessage
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const firstLine = lines[0] ?? rawMessage
return firstLine.length > 180
? `${firstLine.slice(0, 177)}`
: firstLine
}
async function loadRecords() {
if (!props.visible) {
records.value = []
total.value = 0
return
}
loading.value = true
try {
const response = await getHorizonFailedJobs({
current: current.value,
pageSize: pageSize.value,
})
records.value = response.data ?? []
total.value = response.total ?? 0
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '失败作业列表加载失败')
} finally {
loading.value = false
}
}
function handleRefresh() {
if (current.value !== 1) {
current.value = 1
return
}
void loadRecords()
}
function closeDialog() {
emit('update:visible', false)
}
watch(
() => props.visible,
async (visible) => {
if (!visible) {
records.value = []
total.value = 0
return
}
current.value = 1
await loadRecords()
},
{ immediate: true },
)
watch([current, pageSize], () => {
if (!props.visible) {
return
}
void loadRecords()
})
</script>
<template>
<ElDialog
:model-value="props.visible"
width="860px"
class="queue-failed-jobs-dialog"
append-to-body
destroy-on-close
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<template #header>
<div class="dialog-header">
<div>
<p>Queue Failures</p>
<h2>失败作业报错详情</h2>
</div>
<span> {{ total }} 条失败作业</span>
</div>
</template>
<div class="dialog-body">
<div class="dialog-toolbar">
<p>集中查看 Horizon 失败作业的报错摘要失败时间与队列信息</p>
<ElButton text class="ghost-action" :loading="loading" @click="handleRefresh">
重新加载
</ElButton>
</div>
<div class="summary-grid">
<article v-for="item in summaryCards" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<p>{{ item.detail }}</p>
</article>
</div>
<div v-if="!loading && !records.length" class="empty-shell">
<ElEmpty description="当前没有失败作业" />
</div>
<div v-else class="error-list" v-loading="loading">
<article
v-for="(record, index) in records"
:key="`${getIdentifier(record)}-${getFailureTime(record) ?? index}`"
class="error-card"
>
<div class="error-card__header">
<div>
<p>{{ getJobName(record) }}</p>
<span>#{{ getIdentifier(record) }}</span>
</div>
<strong>{{ getQueueName(record) }}</strong>
</div>
<div class="error-card__meta">
<span>失败时间</span>
<strong>{{ formatFailedAt(record) }}</strong>
<span>报错摘要</span>
<strong class="error-card__summary" :title="getErrorMessage(record)">
{{ getErrorSummary(record) }}
</strong>
</div>
</article>
</div>
<footer class="dialog-footer">
<span>当前第 {{ current }} 每页 {{ pageSize }} </span>
<ElPagination
v-model:current-page="current"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="total"
background
/>
</footer>
</div>
</ElDialog>
</template>
<style scoped>
.dialog-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.dialog-header p {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--xboard-text-muted);
}
.dialog-header h2 {
font-size: 30px;
line-height: 1.08;
color: var(--xboard-text-strong);
}
.dialog-header span {
color: var(--xboard-text-secondary);
}
.dialog-body {
display: grid;
gap: 16px;
}
.dialog-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dialog-toolbar p {
margin: 0;
color: var(--xboard-text-secondary);
}
.ghost-action {
color: #0071e3;
padding-inline: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.summary-grid article {
display: grid;
gap: 6px;
padding: 16px 18px;
border-radius: 16px;
background: #f5f5f7;
}
.summary-grid span,
.summary-grid p {
margin: 0;
color: var(--xboard-text-muted);
}
.summary-grid strong {
color: var(--xboard-text-strong);
font-size: 22px;
line-height: 1.14;
}
.error-list {
display: grid;
gap: 12px;
min-height: 120px;
}
.error-card {
display: grid;
gap: 12px;
padding: 18px 20px;
border-radius: 18px;
background: #fbfbfd;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.error-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.error-card__header p,
.error-card__header span,
.error-card__meta span {
margin: 0;
}
.error-card__header p {
color: var(--xboard-text-strong);
font-size: 18px;
line-height: 1.24;
}
.error-card__header span {
color: var(--xboard-text-muted);
font-size: 12px;
}
.error-card__header strong,
.error-card__meta strong {
color: var(--xboard-text-strong);
}
.error-card__meta {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px 14px;
}
.error-card__meta span {
color: var(--xboard-text-muted);
font-size: 12px;
}
.error-card__summary {
line-height: 1.5;
color: #b42318;
word-break: break-word;
}
.empty-shell {
padding: 24px 0;
border-radius: 18px;
background: #fbfbfd;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dialog-footer span {
color: var(--xboard-text-muted);
}
@media (max-width: 767px) {
.dialog-header,
.dialog-toolbar,
.error-card__header,
.dialog-footer {
flex-direction: column;
align-items: flex-start;
}
.summary-grid {
grid-template-columns: 1fr;
}
.error-card__meta {
grid-template-columns: 1fr;
}
}
</style>