<script lang="ts" setup>
|
import type { AgentTaskApi } from '#/api/agent/conversation';
|
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
|
import { message } from 'ant-design-vue';
|
|
import {
|
agentImportErrorMessage,
|
cancelAgentTask,
|
listAgentTasks,
|
streamAgentTaskEvents,
|
} from '#/api/agent/conversation';
|
|
defineOptions({ name: 'AgentTaskCenterDrawer' });
|
|
interface Emits {
|
(event: 'continue', task: AgentTaskApi.Task): void;
|
}
|
|
const props = defineProps<{ enabled: boolean }>();
|
const emit = defineEmits<Emits>();
|
|
const visible = ref(false);
|
const loading = ref(false);
|
const tasks = ref<AgentTaskApi.Task[]>([]);
|
let pollTimer: ReturnType<typeof setTimeout> | undefined;
|
let eventAbort: AbortController | undefined;
|
|
const activeCount = computed(
|
() => tasks.value.filter((task) => !isTerminal(task.status)).length,
|
);
|
|
function isTerminal(status: string) {
|
return ['cancelled', 'failed', 'partially_succeeded', 'succeeded'].includes(
|
status,
|
);
|
}
|
|
function statusText(status: string) {
|
const values: Record<string, string> = {
|
cancelled: '已取消',
|
failed: '失败',
|
partially_succeeded: '部分完成',
|
queued: '排队中',
|
retry_wait: '等待重试',
|
running: '执行中',
|
succeeded: '已完成',
|
waiting_approval: '待审批',
|
waiting_input: '待补充',
|
};
|
return values[status] ?? status;
|
}
|
|
function statusColor(status: string) {
|
if (status === 'succeeded') return 'green';
|
if (status === 'failed' || status === 'cancelled') return 'red';
|
if (status.startsWith('waiting_') || status === 'retry_wait') return 'orange';
|
return 'blue';
|
}
|
|
function stageText(stage: string) {
|
const values: Record<string, string> = {
|
attachment_download: '读取企业附件',
|
cancelled: '任务已取消',
|
document_invalid: '文档校验失败',
|
document_parse: '解析与结构识别',
|
evidence_persist: '保存解析证据',
|
failed: '任务失败',
|
queued: '等待 Worker',
|
reauth_required: '需要重新授权',
|
result_ready: '分析结果已生成',
|
retry_wait: '等待自动重试',
|
starting: '准备执行',
|
worker_lost: 'Worker 多次失联',
|
};
|
return values[stage] ?? stage;
|
}
|
|
function stopPolling() {
|
if (pollTimer) clearTimeout(pollTimer);
|
pollTimer = undefined;
|
eventAbort?.abort();
|
eventAbort = undefined;
|
}
|
|
async function loadTasks(silent = false) {
|
if (!props.enabled) return;
|
if (!silent) loading.value = true;
|
try {
|
const result = await listAgentTasks({ page_size: 50 });
|
tasks.value = result.items ?? [];
|
} catch (error) {
|
if (!silent) message.error(agentImportErrorMessage(error));
|
} finally {
|
if (!silent) loading.value = false;
|
}
|
}
|
|
function schedulePolling() {
|
if (!visible.value || !props.enabled) return;
|
pollTimer = setTimeout(async () => {
|
await loadTasks(true);
|
schedulePolling();
|
}, 3000);
|
}
|
|
async function watchTask(taskId: string, since = 0) {
|
eventAbort?.abort();
|
const controller = new AbortController();
|
eventAbort = controller;
|
let cursor = since;
|
let retryDelay = 1000;
|
while (visible.value && !controller.signal.aborted) {
|
try {
|
cursor = await streamAgentTaskEvents(
|
taskId,
|
cursor,
|
controller.signal,
|
(event) => {
|
const index = tasks.value.findIndex((task) => task.task_id === taskId);
|
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...event };
|
},
|
);
|
retryDelay = 1000;
|
} catch {
|
if (controller.signal.aborted) return;
|
}
|
await loadTasks(true);
|
const latest = tasks.value.find((task) => task.task_id === taskId);
|
if (!latest || isTerminal(latest.status) || controller.signal.aborted) return;
|
cursor = Math.max(cursor, latest.sequence);
|
await new Promise<void>((resolve) => {
|
const timer = setTimeout(resolve, retryDelay);
|
controller.signal.addEventListener(
|
'abort',
|
() => {
|
clearTimeout(timer);
|
resolve();
|
},
|
{ once: true },
|
);
|
});
|
retryDelay = Math.min(retryDelay * 2, 30_000);
|
}
|
}
|
|
async function open(taskId?: string) {
|
stopPolling();
|
visible.value = true;
|
await loadTasks();
|
schedulePolling();
|
const target = taskId
|
? tasks.value.find((task) => task.task_id === taskId)
|
: tasks.value.find((task) => !isTerminal(task.status));
|
if (target && !isTerminal(target.status)) void watchTask(target.task_id, target.sequence);
|
}
|
|
function close() {
|
visible.value = false;
|
stopPolling();
|
}
|
|
async function cancel(task: AgentTaskApi.Task) {
|
try {
|
await cancelAgentTask(task.task_id);
|
message.success('任务已取消');
|
await loadTasks(true);
|
} catch (error) {
|
message.error(agentImportErrorMessage(error));
|
}
|
}
|
|
function continueTask(task: AgentTaskApi.Task) {
|
close();
|
emit('continue', task);
|
}
|
|
defineExpose({ open, activeCount });
|
onBeforeUnmount(stopPolling);
|
</script>
|
|
<template>
|
<a-drawer
|
:open="visible"
|
title="后台任务中心"
|
width="min(560px, 100vw)"
|
:body-style="{ padding: '16px' }"
|
@close="close"
|
>
|
<a-alert
|
v-if="!enabled"
|
type="info"
|
show-icon
|
message="持久任务服务尚未启用"
|
description="配置 PostgreSQL、Redis 并启用 MOM_TASK_BACKEND 后,可在这里查看跨页面执行的文档任务。"
|
/>
|
|
<a-spin v-else :spinning="loading">
|
<a-empty v-if="!tasks.length" description="暂无后台任务" />
|
<div v-else class="space-y-3">
|
<article
|
v-for="task in tasks"
|
:key="task.task_id"
|
class="rounded-lg border border-border bg-card p-4 transition-colors duration-200 hover:border-blue-300"
|
>
|
<div class="flex items-start justify-between gap-3">
|
<div class="min-w-0">
|
<div class="font-medium text-gray-900 dark:text-gray-100">
|
{{ task.result?.filename || '企业文档分析' }}
|
</div>
|
<div class="mt-1 text-xs text-gray-500">
|
{{ stageText(task.stage) }} · 第 {{ task.attempt }} 次执行
|
</div>
|
</div>
|
<a-tag :color="statusColor(task.status)" class="!mr-0 shrink-0">
|
{{ statusText(task.status) }}
|
</a-tag>
|
</div>
|
|
<div class="mt-3" aria-live="polite">
|
<a-progress
|
v-if="task.progress.mode === 'determinate'"
|
:percent="task.progress.percent ?? 0"
|
:status="task.status === 'failed' ? 'exception' : undefined"
|
/>
|
<div
|
v-else
|
class="rounded-md border border-dashed border-border bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
role="status"
|
>
|
当前阶段工作量待确定
|
</div>
|
<div class="mt-1 flex justify-between gap-3 text-xs text-gray-500">
|
<span>{{ task.message }}</span>
|
<span v-if="task.progress.total !== null" class="shrink-0">
|
{{ task.progress.completed }} / {{ task.progress.total }}
|
{{ task.progress.unit === 'row' ? '行' : '' }}
|
</span>
|
</div>
|
</div>
|
|
<a-alert
|
v-if="task.error?.message"
|
class="mt-3"
|
type="error"
|
:message="task.error.message"
|
/>
|
|
<div
|
v-if="task.result?.candidate_count !== undefined"
|
class="mt-3 rounded-md bg-gray-50 px-3 py-2 text-sm dark:bg-gray-800"
|
>
|
已识别 {{ task.result.candidate_count }} 条候选数据
|
<span v-if="task.result.warnings?.length" class="ml-2 text-orange-600">
|
{{ task.result.warnings.length }} 条提示
|
</span>
|
</div>
|
<a-alert
|
v-if="task.result?.review_summary?.requires_review"
|
class="mt-2"
|
type="warning"
|
show-icon
|
:message="`需要人工复核:${task.result.review_summary.conflict_groups} 组冲突,${task.result.review_summary.incomplete_keys} 条缺少归并键`"
|
/>
|
<div
|
v-else-if="task.result?.review_summary?.exact_duplicates_merged"
|
class="mt-2 text-xs text-gray-500"
|
>
|
已自动合并
|
{{ task.result.review_summary.exact_duplicates_merged }} 条完全重复记录
|
</div>
|
|
<div class="mt-3 flex flex-wrap justify-end gap-2">
|
<a-button
|
v-if="task.allowed_actions.includes('cancel')"
|
danger
|
@click="cancel(task)"
|
>
|
取消任务
|
</a-button>
|
<a-button
|
v-if="
|
task.status === 'succeeded' &&
|
task.result?.attachment_id &&
|
task.result?.continuation_supported !== false
|
"
|
type="primary"
|
@click="continueTask(task)"
|
>
|
进入补录确认
|
</a-button>
|
</div>
|
</article>
|
</div>
|
</a-spin>
|
</a-drawer>
|
</template>
|