feat: 中药导入模块修复、部门财务的优化、队列的优化管理

This commit is contained in:
李琦
2026-08-07 09:48:07 +08:00
parent cfc8390573
commit 9f37802ba7
14 changed files with 1380 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
/**
* 业务队列任务列表入口(菜单 name 须与 xk_menu.name 一致)
*/
import QueueJobList from '../queue-job/components/QueueJobList.vue';
import { QUEUE_JOB_CATEGORY } from '../queue-job/config/constants';
defineOptions({ name: 'QueueJobBusiness' });
</script>
<template>
<QueueJobList
:category="QUEUE_JOB_CATEGORY.BUSINESS"
title="业务队列"
/>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
/**
* 基础设施队列任务列表入口(菜单 name 须与 xk_menu.name 一致)
*/
import QueueJobList from '../queue-job/components/QueueJobList.vue';
import { QUEUE_JOB_CATEGORY } from '../queue-job/config/constants';
defineOptions({ name: 'QueueJobInfra' });
</script>
<template>
<QueueJobList
:category="QUEUE_JOB_CATEGORY.INFRA"
title="基础设施队列"
/>
</template>

View File

@@ -0,0 +1,85 @@
/**
* 队列任务监控大屏 API
*/
import { requestClient } from '#/api/request';
const prefix = 'queue-job/';
export type QueueJobMonitorSummary = {
total: number;
pending: number;
pending_total: number;
delayed: number;
running: number;
success: number;
failed: number;
cancelled: number;
fail_rate: number | null;
};
export type QueueJobMonitorTrendItem = {
label: string;
start_at: number;
created: number;
success: number;
failed: number;
};
export type QueueJobMonitorResult = {
now: number;
hours: number;
summary: QueueJobMonitorSummary;
by_category: Array<{
category: number;
category_txt: string;
total: number;
pending: number;
running: number;
success: number;
failed: number;
}>;
trend: QueueJobMonitorTrendItem[];
top_failed_jobs: Array<{
job_name: string;
label: string;
count: number;
}>;
recent_failures: Array<{
id: number;
title: string;
job_name: string;
job_name_txt: string;
category: number;
category_txt: string;
error_message: string;
updated_at: string | number;
created_at: string | number;
}>;
};
/**
* 拉取监控统计hours 为趋势窗口(默认 24
*/
export async function getQueueJobStats(hours = 24) {
return requestClient.get<QueueJobMonitorResult>(`${prefix}stats`, {
params: { hours },
});
}
export type QueueJobProbeResult = {
normal_id: number;
delayed_id: number;
fail_id: number;
delay_seconds: number;
available_at: number;
};
/**
* 超管测试探针:普通 / 延迟 / 失败 各投一条
*/
export async function createQueueJobProbe(data: {
delay_amount: number;
delay_unit: 'second' | 'minute' | 'hour' | 'day';
}) {
return requestClient.post<QueueJobProbeResult>(`${prefix}create-probe`, data);
}

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/**
* 业务 / 基础设施任务量占比
*/
import { nextTick, ref, watch } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
const props = defineProps<{
data: Array<{ category_txt: string; total: number; failed: number }>;
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts, resize } = useEcharts(chartRef);
async function renderChart() {
await renderEcharts({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
name: '任务量',
type: 'pie',
radius: ['42%', '68%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { formatter: '{b}\n{c}' },
data: props.data.map((i) => ({
name: i.category_txt,
value: i.total,
})),
},
],
});
await nextTick();
resize();
}
watch(() => props.data, renderChart, { deep: true, immediate: true });
</script>
<template>
<EchartsUI ref="chartRef" class="h-[320px] w-full" />
</template>

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
/**
* 超管队列探针弹窗:投递普通 / 延迟 / 失败各一条,用于验证监控与 available_at
*/
import { reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Alert,
Form,
FormItem,
InputNumber,
Select,
message,
} from 'ant-design-vue';
import { createQueueJobProbe } from '../api';
const emit = defineEmits<{
success: [];
}>();
const submitting = ref(false);
const form = reactive({
delay_amount: 1,
delay_unit: 'minute' as 'second' | 'minute' | 'hour' | 'day',
});
const unitOptions = [
{ label: '秒', value: 'second' },
{ label: '分钟', value: 'minute' },
{ label: '小时', value: 'hour' },
{ label: '天', value: 'day' },
];
const [Modal, modalApi] = useVbenModal({
title: '创建测试队列任务',
draggable: true,
confirmText: '投递 3 条测试任务',
async onConfirm() {
if (!form.delay_amount || form.delay_amount < 1) {
message.warning('请填写有效的延迟数值');
return;
}
submitting.value = true;
modalApi.setState({ confirmLoading: true });
try {
const res = await createQueueJobProbe({
delay_amount: Number(form.delay_amount),
delay_unit: form.delay_unit,
});
const data = res?.data || res || {};
const availableAt = Number(data.available_at || 0);
const availableTxt = availableAt
? new Date(availableAt * 1000).toLocaleString()
: '-';
message.success(
`已投递:普通#${data.normal_id}、延迟#${data.delayed_id}(执行 ${availableTxt})、失败#${data.fail_id}`,
);
emit('success');
modalApi.close();
} finally {
submitting.value = false;
modalApi.setState({ confirmLoading: false });
}
},
});
</script>
<template>
<Modal class="w-[520px]">
<Alert
type="info"
show-icon
class="mb-4"
message="将同时投递 3 条业务队列探针"
description="① 普通立即执行 ② 延迟执行(下方自定义时间,写入 available_at ③ 故意失败(可验证失败统计/OA。请确认 queue:work 正在消费。"
/>
<Form layout="vertical">
<FormItem label="延迟时间" required>
<div class="flex gap-2">
<InputNumber
v-model:value="form.delay_amount"
:min="1"
:max="9999"
class="flex-1"
placeholder="数值"
/>
<Select
v-model:value="form.delay_unit"
:options="unitOptions"
class="w-[120px]"
/>
</div>
</FormItem>
</Form>
</Modal>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
/**
* 队列任务近 N 小时:新建 / 成功 / 失败趋势
*/
import { nextTick, ref, watch } from 'vue';
import {
EchartsUI,
type EchartsUIType,
useEcharts,
} from '@vben/plugins/echarts';
import type { QueueJobMonitorTrendItem } from '../api';
const props = defineProps<{
data: QueueJobMonitorTrendItem[];
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts, resize } = useEcharts(chartRef);
async function renderChart() {
const labels = props.data.map((item) => item.label);
await renderEcharts({
tooltip: { trigger: 'axis' },
legend: { data: ['新建', '成功', '失败'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
axisLabel: { rotate: labels.length > 12 ? 45 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '新建',
type: 'bar',
data: props.data.map((i) => i.created),
itemStyle: { color: '#1677ff' },
},
{
name: '成功',
type: 'line',
smooth: true,
data: props.data.map((i) => i.success),
itemStyle: { color: '#52c41a' },
},
{
name: '失败',
type: 'line',
smooth: true,
data: props.data.map((i) => i.failed),
itemStyle: { color: '#ff4d4f' },
},
],
});
await nextTick();
resize();
}
watch(() => props.data, renderChart, { deep: true, immediate: true });
</script>
<template>
<EchartsUI ref="chartRef" class="h-[320px] w-full" />
</template>

View File

@@ -0,0 +1,272 @@
<script lang="ts" setup>
/**
* 队列任务监控大屏:实时快照 + 趋势 + 失败 TOP / 最近失败
* 菜单 name 须与 xk_menu.name=QueueJobMonitor 一致
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { AnalysisChartCard, Page, useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import {
Button,
Card,
Radio,
Spin,
Table,
Tag,
message,
} from 'ant-design-vue';
import { getQueueJobStats, type QueueJobMonitorResult } from './api';
import CategoryPieChart from './components/CategoryPieChart.vue';
import ProbeModal from './components/ProbeModal.vue';
import StatusTrendChart from './components/StatusTrendChart.vue';
defineOptions({ name: 'QueueJobMonitor' });
const router = useRouter();
const userStore = useUserStore();
/** 仅超管 role_id=1 可见测试按钮,与后端 SUPPER_ADMIN 一致 */
const isSuperAdmin = computed(() => {
const roleId = Number(
userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id,
);
return roleId === 1;
});
const loading = ref(false);
const hours = ref(24);
const data = ref<QueueJobMonitorResult | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
const [ProbeModalComp, probeModalApi] = useVbenModal({
connectedComponent: ProbeModal,
});
function openProbeModal() {
probeModalApi.open();
}
const summaryCards = computed(() => {
const s = data.value?.summary;
if (!s) return [];
return [
{
key: 'total',
title: '总任务',
value: s.total,
tip: '未删除全部记录',
color: '#1677ff',
},
{
key: 'pending',
title: '排队中',
value: s.pending,
tip: '已就绪待消费',
color: '#faad14',
},
{
key: 'delayed',
title: '延迟队列',
value: s.delayed,
tip: 'available_at > 当前时间',
color: '#722ed1',
},
{
key: 'running',
title: '正在执行',
value: s.running,
tip: 'status=1',
color: '#13c2c2',
},
{
key: 'success',
title: '已完成',
value: s.success,
tip: 'status=2',
color: '#52c41a',
},
{
key: 'failed',
title: '失败',
value: s.failed,
tip:
s.fail_rate == null ? 'status=3' : `失败率 ${s.fail_rate}%`,
color: '#ff4d4f',
},
];
});
const failColumns = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '任务类型', dataIndex: 'job_name_txt', width: 140 },
{ title: '分类', dataIndex: 'category_txt', width: 100 },
{ title: '错误摘要', dataIndex: 'error_message', ellipsis: true },
{ title: '时间', dataIndex: 'updated_at', width: 170 },
];
/**
* 拉取统计;静默刷新时不打断 UI
*/
async function loadStats(silent = false) {
if (!silent) loading.value = true;
try {
const res = await getQueueJobStats(hours.value);
data.value = (res?.data || res) as QueueJobMonitorResult;
} catch {
if (!silent) message.error('加载监控数据失败');
} finally {
if (!silent) loading.value = false;
}
}
function goBusinessFailed() {
router.push({ path: '/log/queue-job/business', query: { status: '3' } });
}
function goInfraFailed() {
router.push({ path: '/log/queue-job/infra', query: { status: '3' } });
}
onMounted(() => {
loadStats();
// 大屏自动刷新,减轻手工点刷新
timer = setInterval(() => loadStats(true), 30_000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
timer = null;
});
</script>
<template>
<Page auto-content-height title="队列任务监控">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="text-sm text-gray-500">
实时快照 · 30 秒自动刷新
<span v-if="data?.now" class="ml-2">
{{ new Date(Number(data.now) * 1000).toLocaleString() }}
</span>
</div>
<div class="flex items-center gap-2">
<Radio.Group
v-model:value="hours"
button-style="solid"
option-type="button"
:options="[
{ label: '近24小时', value: 24 },
{ label: '近7天', value: 168 },
]"
@change="loadStats()"
/>
<Button
v-if="isSuperAdmin"
type="default"
danger
@click="openProbeModal"
>
测试任务
</Button>
<Button type="primary" :loading="loading" @click="loadStats()">
刷新
</Button>
</div>
</div>
<ProbeModalComp @success="loadStats()" />
<Spin :spinning="loading">
<div class="monitor-stat-grid mb-4">
<Card
v-for="card in summaryCards"
:key="card.key"
class="monitor-stat-card"
:bordered="true"
>
<div class="text-sm text-gray-500">{{ card.title }}</div>
<div class="monitor-stat-value" :style="{ color: card.color }">
{{ Number(card.value || 0).toLocaleString() }}
</div>
<div class="mt-2 text-xs text-gray-400">{{ card.tip }}</div>
</Card>
</div>
<div class="mb-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<AnalysisChartCard class="xl:col-span-2" title="任务趋势">
<StatusTrendChart :data="data?.trend || []" />
</AnalysisChartCard>
<AnalysisChartCard title="分类占比">
<CategoryPieChart :data="data?.by_category || []" />
</AnalysisChartCard>
</div>
<div class="mb-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
<Card title="失败 TOP窗口内" :bordered="true">
<div
v-if="!(data?.top_failed_jobs || []).length"
class="py-8 text-center text-gray-400"
>
暂无失败
</div>
<div
v-for="item in data?.top_failed_jobs || []"
:key="item.job_name"
class="mb-3 flex items-center justify-between gap-2"
>
<span class="truncate">{{ item.label }}</span>
<Tag color="error">{{ item.count }}</Tag>
</div>
<div class="mt-2 flex gap-2">
<Button size="small" @click="goBusinessFailed">业务失败列表</Button>
<Button size="small" @click="goInfraFailed">基础设施失败列表</Button>
</div>
</Card>
<Card title="最近失败" :bordered="true">
<Table
size="small"
:pagination="false"
:columns="failColumns"
:data-source="data?.recent_failures || []"
:scroll="{ x: 720 }"
row-key="id"
/>
</Card>
</div>
</Spin>
</Page>
</template>
<style scoped>
.monitor-stat-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 12px;
}
@media (min-width: 768px) {
.monitor-stat-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 1280px) {
.monitor-stat-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.monitor-stat-card :deep(.ant-card-body) {
padding: 16px 18px;
}
.monitor-stat-value {
margin-top: 6px;
font-size: 28px;
font-weight: 700;
line-height: 1.15;
}
</style>

View File

@@ -0,0 +1,48 @@
/**
* 队列任务管理 API业务/基础设施共用,靠 category 区分)
*/
import { requestClient } from '#/api/request';
const prefix = 'queue-job/';
export async function getQueueJobList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getQueueJobDetail(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 某任务的取消/重跑操作记录
*/
export async function getQueueJobOpLogList(id: number) {
return requestClient.get<any>(`${prefix}op-log-list`, { params: { id } });
}
export async function getQueueJobStatusOption() {
return requestClient.get<{ value: number; label: string }[]>(
`${prefix}status-option`,
);
}
export async function getQueueJobNameOption(category: number) {
return requestClient.get<{ value: string; label: string }[]>(
`${prefix}job-name-option`,
{ params: { category } },
);
}
/**
* 取消排队中任务
*/
export async function cancelQueueJob(id: number) {
return requestClient.post<any>(`${prefix}cancel`, { id });
}
/**
* 失败/取消后重跑
*/
export async function retryQueueJob(id: number) {
return requestClient.post<any>(`${prefix}retry`, { id });
}

View File

@@ -0,0 +1,240 @@
<script lang="ts" setup>
/**
* 队列任务列表(业务/基础设施共用)
* 支持详情、取消、重跑;点标题看操作记录;业务队列可筛普通/延迟
*/
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Modal, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {
cancelQueueJob,
getQueueJobNameOption,
getQueueJobStatusOption,
retryQueueJob,
} from '../api';
import {
QUEUE_JOB_CATEGORY,
formatDelaySeconds,
statusTagColor,
} from '../config/constants';
import { createFormOptions } from '../config/search';
import { createGridOptions } from '../config/table';
import DetailModal from './detail-modal.vue';
import OpLogModal from './op-log-modal.vue';
const props = defineProps<{
/** 1 业务 2 基础设施 */
category: number;
/** 页面标题 */
title: string;
}>();
const route = useRoute();
const isBusiness = props.category === QUEUE_JOB_CATEGORY.BUSINESS;
const formOptions = createFormOptions({ showDelayFilter: isBusiness });
const gridOptions = createGridOptions(props.category);
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [DetailModalComp, detailModalApi] = useVbenModal({
connectedComponent: DetailModal,
});
const [OpLogModalComp, opLogModalApi] = useVbenModal({
connectedComponent: OpLogModal,
});
function openDetail(row: Record<string, any>) {
detailModalApi.setData({ id: Number(row?.id || 0), values: row });
detailModalApi.open();
}
/**
* 点标题查看取消/重跑操作记录
*/
function openOpLog(row: Record<string, any>) {
opLogModalApi.setData({
id: Number(row?.id || 0),
title: String(row?.title || ''),
});
opLogModalApi.open();
}
function previewError(msg: unknown) {
try {
const s = typeof msg === 'string' ? msg : JSON.stringify(msg);
if (!s) return '-';
return s.length > 80 ? `${s.slice(0, 80)}` : s;
} catch {
return '-';
}
}
/**
* 取消:仅排队中(status=0)
*/
function onCancel(row: Record<string, any>) {
Modal.confirm({
title: '确认取消该任务?',
content: `任务 #${row.id}${row.title || ''}」将被标记为已取消worker 取到后不再执行。`,
onOk: async () => {
await cancelQueueJob(Number(row.id));
message.success('已取消');
gridApi.query();
},
});
}
/**
* 重跑:失败(3)/取消(4),有副作用风险需二次确认
*/
function onRetry(row: Record<string, any>) {
Modal.confirm({
title: '确认重新执行?',
content:
`将基于 #${row.id} 新建一条任务并重新投递。` +
'订单/退款等业务重跑可能产生副作用,请确认业务幂等后再操作。',
okType: 'danger',
onOk: async () => {
const res = await retryQueueJob(Number(row.id));
const newId = res?.id ?? res?.data?.id;
message.success(newId ? `已重新投递,新任务 #${newId}` : '已重新投递');
gridApi.query();
},
});
}
function buildActions(row: Record<string, any>) {
const status = Number(row.status);
const actions: any[] = [
{
label: '详情',
type: 'link',
size: 'small',
onClick: () => openDetail(row),
},
];
if (status === 0) {
actions.push({
label: '取消',
type: 'link',
size: 'small',
onClick: () => onCancel(row),
});
}
if (status === 3 || status === 4) {
actions.push({
label: '重跑',
type: 'link',
size: 'small',
onClick: () => onRetry(row),
});
}
return actions;
}
onMounted(async () => {
try {
const [statusOpts, jobOpts] = await Promise.all([
getQueueJobStatusOption(),
getQueueJobNameOption(props.category),
]);
const statusOptions = Array.isArray(statusOpts)
? statusOpts
: statusOpts?.data || [];
const jobOptions = Array.isArray(jobOpts) ? jobOpts : jobOpts?.data || [];
gridApi.formApi?.updateSchema?.([
{
fieldName: 'status',
componentProps: {
allowClear: true,
options: statusOptions,
placeholder: '全部',
},
},
{
fieldName: 'job_name',
componentProps: {
allowClear: true,
options: jobOptions,
placeholder: '全部',
showSearch: true,
optionFilterProp: 'label',
},
},
]);
// 监控页跳转可带 ?status=3 预填筛选
const qStatus = route.query.status;
if (qStatus !== undefined && qStatus !== null && qStatus !== '') {
await gridApi.formApi?.setValues?.({ status: Number(qStatus) });
gridApi.reload?.();
}
} catch {
// 接口失败时沿用 constants 兜底
}
});
</script>
<template>
<Page auto-content-height :title="title">
<DetailModalComp />
<OpLogModalComp />
<Grid>
<template #toolbar-buttons />
<template #title="{ row }">
<a
class="cursor-pointer text-primary"
title="查看操作记录"
@click.prevent="openOpLog(row)"
>
{{ row.title || '-' }}
</a>
</template>
<template #delay_type="{ row }">
<Tag :color="Number(row.is_delayed) === 1 ? 'purple' : 'default'">
{{ row.delay_type_txt || (Number(row.is_delayed) === 1 ? '延迟' : '普通') }}
</Tag>
</template>
<template #status="{ row }">
<Tag :color="statusTagColor(Number(row.status))">
{{ row.status_txt || row.status }}
</Tag>
</template>
<template #error="{ row }">
<span class="text-xs text-gray-600">{{
previewError(row.error_message)
}}</span>
</template>
<template #available_at="{ row }">
<div>
<div>{{ row.available_at || row.created_at || '-' }}</div>
<Tag
v-if="Number(row.is_delayed) === 1"
color="purple"
class="mt-1"
>
延迟
{{
Number(row.delay_seconds) > 0
? formatDelaySeconds(Number(row.delay_seconds))
: ''
}}
</Tag>
</div>
</template>
<template #action="{ row }">
<TableAction :actions="buildActions(row)" />
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,157 @@
<script lang="ts" setup>
/**
* 队列任务详情:展示 payload、错误信息、重跑链路
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Tag } from 'ant-design-vue';
import { getQueueJobDetail } from '../api';
import { statusTagColor } from '../config/constants';
const data = ref<Record<string, any>>({});
const loading = ref(false);
function formatJson(val: unknown): string {
if (val == null || val === '') {
return '(无)';
}
try {
return JSON.stringify(val, null, 2);
} catch {
return String(val);
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: true,
draggable: true,
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
data.value = {};
return;
}
const payload = modalApi.getData<{
id?: number;
values?: Record<string, any>;
}>();
const id = Number(payload?.id || payload?.values?.id || 0);
if (id <= 0) {
data.value = payload?.values || {};
return;
}
loading.value = true;
modalApi.setState({ loading: true });
try {
const res = await getQueueJobDetail(id);
data.value = res?.data || res || {};
} catch {
data.value = payload?.values || {};
} finally {
loading.value = false;
modalApi.setState({ loading: false });
}
},
});
</script>
<template>
<Modal class="w-[90%] md:w-[780px]" title="队列任务详情">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
size="small"
>
<Descriptions.Item label="ID">{{ data.id }}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag :color="statusTagColor(Number(data.status))">
{{ data.status_txt || data.status }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="标题" :span="2">
{{ data.title || '-' }}
</Descriptions.Item>
<Descriptions.Item label="任务类型">
{{ data.job_name_txt || '-' }}
</Descriptions.Item>
<Descriptions.Item label="分类">
{{ data.category_txt || data.category }}
</Descriptions.Item>
<Descriptions.Item label="操作人">
{{ data.user_name || '-' }}ID: {{ data.user_id || 0 }}
</Descriptions.Item>
<Descriptions.Item label="耗时">
{{ data.time_difference || '-' }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ data.created_at || '-' }}
</Descriptions.Item>
<Descriptions.Item label="执行时间">
{{ data.available_at || data.created_at || '-' }}
<Tag v-if="Number(data.is_delayed) === 1" color="purple" class="ml-1">
延迟
</Tag>
<span
v-if="Number(data.is_delayed) === 1 && Number(data.delay_seconds) > 0"
class="ml-1 text-xs text-gray-500"
>
相对创建 {{ data.delay_type_txt || '延迟' }}
</span>
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{{ data.updated_at || '-' }}
</Descriptions.Item>
<Descriptions.Item label="简介" :span="2">
{{ data.detail || '-' }}
</Descriptions.Item>
<Descriptions.Item label="任务类名" :span="2">
<code class="break-all text-xs">{{ data.job_name || '-' }}</code>
</Descriptions.Item>
<Descriptions.Item label="错误信息" :span="2">
<pre class="snap-pre">{{ data.error_message || '(无)' }}</pre>
</Descriptions.Item>
<Descriptions.Item label="重跑参数 payload" :span="2">
<pre class="snap-pre">{{ formatJson(data.payload) }}</pre>
</Descriptions.Item>
<Descriptions.Item label="重跑链路" :span="2">
<div v-if="Array.isArray(data.retry_chain) && data.retry_chain.length">
<div
v-for="item in data.retry_chain"
:key="item.id"
class="mb-1 text-xs"
>
#{{ item.id }}
<Tag :color="statusTagColor(Number(item.status))" class="mx-1">
{{ item.status_txt }}
</Tag>
{{ item.title }}
<span class="text-gray-400">{{ item.created_at }}</span>
<span v-if="item.retry_from_id" class="text-gray-400">
来自 #{{ item.retry_from_id }}
</span>
</div>
</div>
<span v-else>-</span>
</Descriptions.Item>
</Descriptions>
</Modal>
</template>
<style scoped>
.snap-pre {
margin: 0;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
/**
* 队列任务操作记录弹窗:展示取消 / 重跑审计
* 由列表点击标题打开
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Empty, Tag, Timeline } from 'ant-design-vue';
import { getQueueJobOpLogList } from '../api';
import { opTypeTagColor } from '../config/constants';
const title = ref('操作记录');
const items = ref<Record<string, any>[]>([]);
const loading = ref(false);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
items.value = [];
title.value = '操作记录';
return;
}
const payload = modalApi.getData<{
id?: number;
title?: string;
}>();
const id = Number(payload?.id || 0);
const jobTitle = String(payload?.title || '');
title.value = jobTitle
? `操作记录 · #${id} ${jobTitle}`
: `操作记录 · #${id}`;
if (id <= 0) {
items.value = [];
return;
}
loading.value = true;
modalApi.setState({ loading: true });
try {
const res = await getQueueJobOpLogList(id);
const data = res?.data || res || {};
items.value = Array.isArray(data.items) ? data.items : [];
if (data.title) {
title.value = `操作记录 · #${id} ${data.title}`;
}
} catch {
items.value = [];
} finally {
loading.value = false;
modalApi.setState({ loading: false });
}
},
});
</script>
<template>
<Modal class="w-[90%] md:w-[560px]" :title="title">
<Empty v-if="!loading && items.length === 0" description="暂无取消/重跑记录" />
<Timeline v-else>
<Timeline.Item v-for="row in items" :key="row.id">
<div class="mb-1 flex items-center gap-2">
<Tag :color="opTypeTagColor(Number(row.op_type))">
{{ row.op_type_txt || row.op_type }}
</Tag>
<span class="text-xs text-gray-400">{{ row.created_at || '-' }}</span>
</div>
<div class="text-sm">
操作人{{ row.operator_name || '-' }}
<span class="text-gray-400">ID: {{ row.operator_id || 0 }}</span>
</div>
<div v-if="Number(row.related_job_id) > 0" class="text-sm text-gray-600">
关联任务#{{ row.related_job_id }}
</div>
<div v-if="row.remark" class="text-xs text-gray-500">
{{ row.remark }}
</div>
</Timeline.Item>
</Timeline>
</Modal>
</template>

View File

@@ -0,0 +1,54 @@
/**
* 队列任务状态/分类常量(与后端 Enum 对齐,接口失败时作兜底)
*/
export const QUEUE_JOB_STATUS_OPTIONS = [
{ label: '排队中', value: 0 },
{ label: '正在执行', value: 1 },
{ label: '已完成', value: 2 },
{ label: '队列异常', value: 3 },
{ label: '已取消', value: 4 },
];
/** 普通 / 延迟 筛选(与后端 delay_type 对齐) */
export const QUEUE_JOB_DELAY_TYPE_OPTIONS = [
{ label: '普通队列', value: 0 },
{ label: '延迟队列', value: 1 },
];
/** 1 业务 2 基础设施 */
export const QUEUE_JOB_CATEGORY = {
BUSINESS: 1,
INFRA: 2,
} as const;
export function statusTagColor(status: number): string {
if (status === 0) return 'default';
if (status === 1) return 'processing';
if (status === 2) return 'success';
if (status === 3) return 'error';
if (status === 4) return 'warning';
return 'default';
}
/**
* 延迟秒数可读文案(列表「延迟」标签旁展示)
*/
export function formatDelaySeconds(seconds: number): string {
const s = Math.max(0, Math.floor(Number(seconds) || 0));
if (s < 60) return `${s}`;
if (s < 3600) return `${Math.round(s / 60)}分钟`;
if (s < 86400) {
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return m > 0 ? `${h}小时${m}` : `${h}小时`;
}
const d = Math.floor(s / 86400);
const h = Math.round((s % 86400) / 3600);
return h > 0 ? `${d}${h}小时` : `${d}`;
}
export function opTypeTagColor(opType: number): string {
if (opType === 1) return 'warning';
if (opType === 2) return 'processing';
return 'default';
}

View File

@@ -0,0 +1,106 @@
import type { VbenFormProps } from '#/adapter/form';
import dayjs from 'dayjs';
import {
QUEUE_JOB_DELAY_TYPE_OPTIONS,
QUEUE_JOB_STATUS_OPTIONS,
} from './constants';
/**
* 队列任务搜索表单
* 业务队列额外提供「普通/延迟」筛选
*/
export function createFormOptions(options?: {
showDelayFilter?: boolean;
}): VbenFormProps {
const schema: VbenFormProps['schema'] = [
{
component: 'VbenInput',
componentProps: {
placeholder: '标题 / 简介 / 错误 / 任务类',
allowClear: true,
},
fieldName: 'keyword',
label: '关键词',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [...QUEUE_JOB_STATUS_OPTIONS],
placeholder: '全部',
},
fieldName: 'status',
label: '状态',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [],
placeholder: '全部',
showSearch: true,
optionFilterProp: 'label',
},
fieldName: 'job_name',
label: '任务类型',
},
];
// 业务队列:普通 / 延迟 筛选
if (options?.showDelayFilter) {
schema.push({
component: 'Select',
componentProps: {
allowClear: true,
options: [...QUEUE_JOB_DELAY_TYPE_OPTIONS],
placeholder: '全部',
},
fieldName: 'delay_type',
label: '队列类型',
});
}
schema.push({
component: 'RangePicker',
componentProps: {
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
},
// 默认近 7 天
defaultValue: [
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
],
fieldName: 'search_time',
label: '时间范围',
});
return {
collapsed: false,
schema,
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: false,
submitOnEnter: true,
};
}
/**
* search_time → start_time/end_time 时间戳
*/
export function normalizeQueueJobFilters(formValues: Record<string, any> = {}) {
const params: Record<string, any> = { ...formValues };
const range = params.search_time;
delete params.search_time;
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
params.start_time = dayjs(range[0]).startOf('day').unix();
params.end_time = dayjs(range[1]).endOf('day').unix();
}
Object.keys(params).forEach((k) => {
if (params[k] === '' || params[k] === undefined || params[k] === null) {
delete params[k];
}
});
return params;
}

View File

@@ -0,0 +1,84 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getQueueJobList } from '../api';
import { normalizeQueueJobFilters } from './search';
/**
* 按分类生成表格配置(业务/基础设施共用列)
* 标题可点开操作记录;执行时间列展示延迟标记
*/
export function createGridOptions(category: number): VxeGridProps<any> {
return {
checkboxConfig: {
highlight: true,
labelField: '',
},
columns: [
{ field: 'id', title: 'ID', width: 80 },
{
field: 'title',
title: '标题',
minWidth: 160,
slots: { default: 'title' },
},
{ field: 'job_name_txt', title: '任务类型', minWidth: 140 },
{
field: 'delay_type_txt',
title: '队列类型',
width: 100,
slots: { default: 'delay_type' },
},
{
field: 'status_txt',
title: '状态',
width: 100,
slots: { default: 'status' },
},
{ field: 'time_difference', title: '耗时', width: 90 },
{ field: 'user_name', title: '操作人', width: 100 },
{
field: 'error_message',
title: '错误摘要',
minWidth: 160,
slots: { default: 'error' },
},
{ field: 'retry_from_id', title: '重跑自', width: 80 },
{ field: 'created_at', title: '创建时间', width: 170 },
{
field: 'available_at',
title: '执行时间',
width: 200,
slots: { default: 'available_at' },
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'action' },
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getQueueJobList({
page: page.currentPage,
pageSize: page.pageSize,
category,
...normalizeQueueJobFilters(formValues || {}),
});
},
},
},
height: 'auto',
toolbarConfig: {
search: true,
refresh: true,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
},
},
};
}