feat(ops): add job center for cross-source job operations

Provide a unified ops console for inspecting, retrying and cancelling
jobs across generation, template/kol assist, knowledge parse, desktop
publish/task, compliance review and monitoring collect sources. Wires
RabbitMQ for retry republish and consolidates the desktop_publish_jobs
columns into the base migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:56:39 +08:00
parent ab62d666b4
commit 7b4d7ccf68
13 changed files with 3043 additions and 12 deletions
+7
View File
@@ -53,6 +53,7 @@ import {
CrownOutlined,
FileSearchOutlined,
GlobalOutlined,
PartitionOutlined,
LineChartOutlined,
SafetyCertificateOutlined,
SettingOutlined,
@@ -87,6 +88,7 @@ const menuLeaves: MenuLeaf[] = [
{ key: '/compliance/records', label: '检测记录', path: '/compliance/records' },
{ key: '/compliance/stats', label: '合规统计', path: '/compliance/stats' },
{ key: '/accounts', label: '操作员管理', path: '/accounts' },
{ key: '/jobs', label: '任务中心', path: '/jobs' },
{ key: '/audits', label: '审计日志', path: '/audits' },
]
@@ -148,6 +150,11 @@ const menuItems = computed<ItemType[]>(() => [
label: '操作员管理',
icon: () => h(SafetyCertificateOutlined),
},
{
key: '/jobs',
label: '任务中心',
icon: () => h(PartitionOutlined),
},
{
key: '/audits',
label: '审计日志',
+6
View File
@@ -42,6 +42,12 @@ export const router = createRouter({
component: () => import('@/views/AuditLogsView.vue'),
meta: { title: '审计日志' },
},
{
path: 'jobs',
name: 'jobs',
component: () => import('@/views/JobsView.vue'),
meta: { title: '任务中心' },
},
{
path: 'site-domain-mappings',
name: 'site-domain-mappings',
+574
View File
@@ -0,0 +1,574 @@
<template>
<div class="jobs-page">
<div class="jobs-header">
<div>
<h2 class="ops-page-title">任务中心</h2>
<div class="jobs-header__meta">内部任务排障追踪重试与取消</div>
</div>
<a-button :loading="loading" @click="reload">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<div class="jobs-summary">
<div v-for="item in phaseSummary" :key="item.key" class="jobs-summary__item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="ops-card">
<div class="ops-toolbar jobs-toolbar">
<a-input
v-model:value="filter.keyword"
placeholder="搜索 ID / 标题 / 错误 / 租户"
style="width: 280px"
allow-clear
@press-enter="resetAndReload"
/>
<a-select
v-model:value="filter.source"
class="jobs-select"
:options="sourceOptions"
allow-clear
placeholder="任务来源"
@change="resetAndReload"
/>
<a-select
v-model:value="filter.phase"
class="jobs-select"
:options="phaseOptions"
allow-clear
placeholder="阶段"
@change="resetAndReload"
/>
<a-input v-model:value="filter.kind" placeholder="类型" style="width: 160px" allow-clear @press-enter="resetAndReload" />
<a-input v-model:value="filter.tenantId" placeholder="租户 ID" style="width: 120px" allow-clear @press-enter="resetAndReload" />
<a-range-picker v-model:value="dateRange" show-time style="width: 360px" @change="resetAndReload" />
<a-button @click="resetAndReload">查询</a-button>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="uid"
:scroll="{ x: 1420 }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'job'">
<div class="job-main">
<a-button type="link" class="job-link" @click="openDetail(record)">
{{ record.title || sourceLabel(record.source) }}
</a-button>
<div class="job-sub">
<code>{{ record.source }}:{{ record.id }}</code>
<a-tag v-if="record.is_stuck" color="red">疑似卡住</a-tag>
</div>
</div>
</template>
<template v-else-if="column.key === 'phase'">
<a-tag :color="phaseColor(record.phase)">
{{ phaseLabel(record.phase) }}
</a-tag>
<div class="job-status">{{ record.status }}</div>
</template>
<template v-else-if="column.key === 'tenant'">
<div>{{ record.tenant_name || '—' }}</div>
<div class="muted">T{{ record.tenant_id || '—' }} / W{{ record.workspace_id || '—' }}</div>
</template>
<template v-else-if="column.key === 'worker'">
<div class="mono-line">{{ record.worker || record.queue || '—' }}</div>
<div class="muted">尝试 {{ record.attempts ?? 0 }}</div>
</template>
<template v-else-if="column.key === 'time'">
<div>{{ formatDate(record.created_at) }}</div>
<div class="muted">耗时 {{ formatDuration(record.duration_seconds) }}</div>
</template>
<template v-else-if="column.key === 'error'">
<span class="error-text">{{ shortText(record.error_message) || '—' }}</span>
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row">
<a-tooltip title="详情">
<a-button type="text" class="action-btn action-edit" @click="openDetail(record)">
<FileSearchOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="重试">
<a-popconfirm title="确认重试该任务?" ok-text="重试" cancel-text="取消" @confirm="retryJob(record)">
<a-button type="text" class="action-btn action-play" :disabled="!record.can_retry">
<ReloadOutlined />
</a-button>
</a-popconfirm>
</a-tooltip>
<a-tooltip title="取消">
<a-popconfirm title="确认取消该任务?" ok-text="取消任务" cancel-text="关闭" @confirm="cancelJob(record)">
<a-button type="text" class="action-btn action-delete" :disabled="!record.can_cancel">
<StopOutlined />
</a-button>
</a-popconfirm>
</a-tooltip>
</div>
</template>
</template>
</a-table>
</div>
<a-drawer v-model:open="detailOpen" width="760" :title="detailTitle" :destroy-on-close="true">
<a-spin :spinning="detailLoading">
<div v-if="selectedDetail" class="detail-stack">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="来源">{{ sourceLabel(selectedDetail.source) }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="phaseColor(selectedDetail.phase)">{{ phaseLabel(selectedDetail.phase) }}</a-tag>
{{ selectedDetail.status }}
</a-descriptions-item>
<a-descriptions-item label="任务 ID">
<code>{{ selectedDetail.id }}</code>
</a-descriptions-item>
<a-descriptions-item label="类型">{{ selectedDetail.kind }}</a-descriptions-item>
<a-descriptions-item label="租户">{{ selectedDetail.tenant_name || selectedDetail.tenant_id || '—' }}</a-descriptions-item>
<a-descriptions-item label="Workspace">{{ selectedDetail.workspace_name || selectedDetail.workspace_id || '—' }}</a-descriptions-item>
<a-descriptions-item label="队列">{{ selectedDetail.queue || '—' }}</a-descriptions-item>
<a-descriptions-item label="Worker">{{ selectedDetail.worker || '—' }}</a-descriptions-item>
<a-descriptions-item label="创建">{{ formatDate(selectedDetail.created_at) }}</a-descriptions-item>
<a-descriptions-item label="更新">{{ formatDate(selectedDetail.updated_at) }}</a-descriptions-item>
</a-descriptions>
<section v-if="selectedDetail.error_message || selectedDetail.error" class="detail-section">
<h3>错误</h3>
<pre>{{ formatJSON(selectedDetail.error || selectedDetail.error_message) }}</pre>
</section>
<section class="detail-section">
<h3>Payload</h3>
<pre>{{ formatJSON(selectedDetail.payload) }}</pre>
</section>
<section v-if="selectedDetail.result" class="detail-section">
<h3>Result</h3>
<pre>{{ formatJSON(selectedDetail.result) }}</pre>
</section>
<section v-if="selectedDetail.metadata" class="detail-section">
<h3>Metadata</h3>
<pre>{{ formatJSON(selectedDetail.metadata) }}</pre>
</section>
</div>
</a-spin>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { FileSearchOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons-vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs, { type Dayjs } from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import { OpsApiError, http } from '@/lib/http'
interface JobSummary {
uid: string
source: string
id: string
numeric_id?: number | null
uuid?: string | null
kind: string
status: string
phase: string
tenant_id?: number | null
tenant_name?: string | null
workspace_id?: number | null
workspace_name?: string | null
user_id?: number | null
user_name?: string | null
title?: string | null
target?: string | null
queue?: string | null
worker?: string | null
attempts?: number | null
priority?: number | null
error_message?: string | null
created_at: string
updated_at: string
duration_seconds?: number | null
is_stuck: boolean
can_retry: boolean
can_cancel: boolean
}
interface JobDetail extends JobSummary {
payload?: unknown
result?: unknown
error?: unknown
metadata?: Record<string, unknown> | null
}
interface JobsResult {
items: JobSummary[]
total: number
page: number
size: number
counts: {
total: number
by_phase: Record<string, number>
by_source: Record<string, number>
}
}
const sourceOptions = [
{ label: '内容生成', value: 'generation' },
{ label: '模板助手', value: 'template_assist' },
{ label: 'KOL 助手', value: 'kol_assist' },
{ label: '知识库解析', value: 'knowledge_parse' },
{ label: '发布 Job', value: 'desktop_publish' },
{ label: '桌面任务', value: 'desktop_task' },
{ label: '合规复核', value: 'compliance_review' },
{ label: '监控采集', value: 'monitoring_collect' },
]
const phaseOptions = [
{ label: '排队中', value: 'queued' },
{ label: '运行中', value: 'running' },
{ label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' },
{ label: '取消', value: 'canceled' },
{ label: '阻断', value: 'blocked' },
{ label: '未知', value: 'unknown' },
]
const columns = [
{ title: '任务', key: 'job', width: 330, fixed: 'left' },
{ title: '阶段', key: 'phase', width: 120 },
{ title: '类型', dataIndex: 'kind', key: 'kind', width: 170 },
{ title: '租户 / Workspace', key: 'tenant', width: 190 },
{ title: '执行端', key: 'worker', width: 220 },
{ title: '时间', key: 'time', width: 190 },
{ title: '错误摘要', key: 'error', width: 260 },
{ title: '操作', key: 'actions', width: 126, fixed: 'right' },
]
const filter = reactive({
keyword: '',
source: undefined as string | undefined,
phase: undefined as string | undefined,
kind: '',
tenantId: '',
})
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const rows = ref<JobSummary[]>([])
const counts = ref<JobsResult['counts']>({ total: 0, by_phase: {}, by_source: {} })
const loading = ref(false)
const page = ref(1)
const size = ref(50)
const total = ref(0)
const detailOpen = ref(false)
const detailLoading = ref(false)
const selectedDetail = ref<JobDetail | null>(null)
const pagination = computed<TablePaginationConfig>(() => ({
current: page.value,
pageSize: size.value,
total: total.value,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
}))
const phaseSummary = computed(() =>
phaseOptions.map((option) => ({
key: option.value,
label: option.label,
value: counts.value.by_phase[option.value] ?? 0,
})),
)
const detailTitle = computed(() => {
if (!selectedDetail.value) return '任务详情'
return `${sourceLabel(selectedDetail.value.source)} · ${selectedDetail.value.id}`
})
async function reload() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: page.value,
size: size.value,
}
if (filter.keyword.trim()) params.keyword = filter.keyword.trim()
if (filter.source) params.source = filter.source
if (filter.phase) params.phase = filter.phase
if (filter.kind.trim()) params.kind = filter.kind.trim()
if (filter.tenantId.trim()) {
const id = Number(filter.tenantId.trim())
if (!Number.isFinite(id) || id <= 0) {
message.warning('租户 ID 必须是正整数')
return
}
params.tenant_id = id
}
if (dateRange.value) {
params.start_at = dateRange.value[0].toISOString()
params.end_at = dateRange.value[1].toISOString()
}
const result = await http.get<JobsResult>('/jobs', params)
rows.value = result.items
total.value = result.total
counts.value = result.counts
} catch (error) {
showError(error)
} finally {
loading.value = false
}
}
function resetAndReload() {
page.value = 1
void reload()
}
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1
size.value = pag.pageSize ?? 50
void reload()
}
async function openDetail(row: JobSummary) {
detailOpen.value = true
detailLoading.value = true
selectedDetail.value = null
try {
selectedDetail.value = await http.get<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}`)
} catch (error) {
showError(error)
} finally {
detailLoading.value = false
}
}
async function retryJob(row: JobSummary) {
try {
const item = await http.post<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}/retry`)
message.success('已提交重试')
patchRow(item)
if (selectedDetail.value?.uid === item.uid) selectedDetail.value = item
} catch (error) {
showError(error)
}
}
async function cancelJob(row: JobSummary) {
try {
const item = await http.post<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}/cancel`, {
reason: 'ops console cancel',
})
message.success('已取消任务')
patchRow(item)
if (selectedDetail.value?.uid === item.uid) selectedDetail.value = item
} catch (error) {
showError(error)
}
}
function patchRow(item: JobSummary) {
const index = rows.value.findIndex((row) => row.uid === item.uid)
if (index >= 0) {
rows.value[index] = item
}
}
function sourceLabel(source: string): string {
return sourceOptions.find((item) => item.value === source)?.label ?? source
}
function phaseLabel(phase: string): string {
return phaseOptions.find((item) => item.value === phase)?.label ?? phase
}
function phaseColor(phase: string): string {
if (phase === 'queued') return 'blue'
if (phase === 'running') return 'gold'
if (phase === 'succeeded') return 'green'
if (phase === 'failed') return 'red'
if (phase === 'blocked') return 'volcano'
if (phase === 'canceled') return 'default'
return 'purple'
}
function formatDate(value?: string | null): string {
if (!value) return '—'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
function formatDuration(seconds?: number | null): string {
if (seconds == null) return '—'
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
if (minutes < 60) return `${minutes}m ${rest}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}
function shortText(value?: string | null): string {
if (!value) return ''
return value.length > 110 ? `${value.slice(0, 110)}...` : value
}
function formatJSON(value: unknown): string {
if (value == null || value === '') return '—'
if (typeof value === 'string') return value
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function showError(error: unknown) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message)
} else {
message.error('请求失败')
}
}
onMounted(() => {
void reload()
})
</script>
<style scoped>
.jobs-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.jobs-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.jobs-header__meta {
color: #64748b;
font-size: 13px;
}
.jobs-summary {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 12px;
}
.jobs-summary__item {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
}
.jobs-summary__item span {
display: block;
color: #64748b;
font-size: 12px;
}
.jobs-summary__item strong {
display: block;
margin-top: 4px;
color: #0f172a;
font-size: 22px;
line-height: 1.1;
}
.jobs-select {
width: 150px;
}
.job-main {
min-width: 0;
}
.job-link {
height: auto;
padding: 0;
font-weight: 650;
white-space: normal;
text-align: left;
}
.job-sub {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.job-status,
.muted {
margin-top: 2px;
color: #94a3b8;
font-size: 12px;
}
.mono-line {
max-width: 190px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: #334155;
font-size: 12px;
}
.error-text {
color: #b91c1c;
font-size: 12px;
}
.detail-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-section h3 {
margin: 0 0 8px;
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.detail-section pre {
max-height: 340px;
overflow: auto;
margin: 0;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.55;
}
@media (max-width: 1180px) {
.jobs-summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.jobs-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>