chore: vendor FBA UI 工程源码(fastapi-best-architecture-ui master,Epic #163 受控源码基础)
This commit is contained in:
+552
@@ -0,0 +1,552 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Cron } from 'croner';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { CRONTAB_PRESETS } from './data';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
value?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [value: string];
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const WEEKDAY_LABELS: Record<number, string> = {
|
||||
0: '周日',
|
||||
1: '周一',
|
||||
2: '周二',
|
||||
3: '周三',
|
||||
4: '周四',
|
||||
5: '周五',
|
||||
6: '周六',
|
||||
};
|
||||
|
||||
interface CronField {
|
||||
mode: 'all' | 'specific' | 'step';
|
||||
step: number;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
const CRON_FIELD_KEYS = ['minute', 'hour', 'day', 'month', 'weekday'] as const;
|
||||
|
||||
type CronFieldKey = (typeof CRON_FIELD_KEYS)[number];
|
||||
|
||||
const activeTab = ref('minute');
|
||||
|
||||
const segmentOptions = [
|
||||
{ label: '分', value: 'minute' },
|
||||
{ label: '时', value: 'hour' },
|
||||
{ label: '日', value: 'day' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '周', value: 'weekday' },
|
||||
];
|
||||
|
||||
const cronBuilder = reactive<{
|
||||
day: CronField;
|
||||
hour: CronField;
|
||||
minute: CronField;
|
||||
month: CronField;
|
||||
weekday: CronField;
|
||||
}>({
|
||||
minute: { mode: 'all', step: 1, values: [] },
|
||||
hour: { mode: 'all', step: 1, values: [] },
|
||||
day: { mode: 'all', step: 1, values: [] },
|
||||
month: { mode: 'all', step: 1, values: [] },
|
||||
weekday: { mode: 'all', step: 1, values: [] },
|
||||
});
|
||||
|
||||
function resetCronBuilder() {
|
||||
for (const key of CRON_FIELD_KEYS) {
|
||||
cronBuilder[key].mode = 'all';
|
||||
cronBuilder[key].step = 1;
|
||||
cronBuilder[key].values = [];
|
||||
}
|
||||
}
|
||||
|
||||
function cronFieldToString(field: CronField): string {
|
||||
if (field.mode === 'all') return '*';
|
||||
if (field.mode === 'step') return `*/${field.step}`;
|
||||
if (field.values.length === 0) return '*';
|
||||
return [...field.values].toSorted((a, b) => a - b).join(',');
|
||||
}
|
||||
|
||||
function expandCronPart(part: string): number[] {
|
||||
const values: number[] = [];
|
||||
for (const segment of part.split(',')) {
|
||||
if (segment.includes('-')) {
|
||||
const nums = segment.split('-').map((v) => Number.parseInt(v, 10));
|
||||
const start = nums[0];
|
||||
const end = nums[1];
|
||||
if (
|
||||
start !== undefined &&
|
||||
end !== undefined &&
|
||||
!Number.isNaN(start) &&
|
||||
!Number.isNaN(end)
|
||||
) {
|
||||
for (let i = start; i <= end; i++) values.push(i);
|
||||
}
|
||||
} else {
|
||||
const v = Number.parseInt(segment, 10);
|
||||
if (!Number.isNaN(v)) values.push(v);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseCronPart(key: CronFieldKey, part: string) {
|
||||
if (part === '*') {
|
||||
cronBuilder[key].mode = 'all';
|
||||
} else if (part.startsWith('*/')) {
|
||||
cronBuilder[key].mode = 'step';
|
||||
cronBuilder[key].step = Number.parseInt(part.slice(2), 10) || 1;
|
||||
} else {
|
||||
cronBuilder[key].mode = 'specific';
|
||||
cronBuilder[key].values = expandCronPart(part);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCronExpression(expr: string): string {
|
||||
return expr
|
||||
.split(/\s+/)
|
||||
.map((part) => {
|
||||
if (part === '*' || part.startsWith('*/')) return part;
|
||||
return expandCronPart(part)
|
||||
.toSorted((a, b) => a - b)
|
||||
.join(',');
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const cronParts = computed(() => ({
|
||||
minute: cronFieldToString(cronBuilder.minute),
|
||||
hour: cronFieldToString(cronBuilder.hour),
|
||||
day: cronFieldToString(cronBuilder.day),
|
||||
month: cronFieldToString(cronBuilder.month),
|
||||
weekday: cronFieldToString(cronBuilder.weekday),
|
||||
}));
|
||||
|
||||
const cronExpression = computed(() => {
|
||||
const p = cronParts.value;
|
||||
return `${p.minute} ${p.hour} ${p.day} ${p.month} ${p.weekday}`;
|
||||
});
|
||||
|
||||
const cronPreviewRuns = computed(() => {
|
||||
try {
|
||||
return new Cron(cronExpression.value).nextRuns(5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
function parseCronExpression(expr: string) {
|
||||
const parts = expr.split(/\s+/);
|
||||
if (parts.length !== 5) return;
|
||||
|
||||
CRON_FIELD_KEYS.forEach((key, i) => {
|
||||
parseCronPart(key, parts[i] || '*');
|
||||
});
|
||||
}
|
||||
|
||||
function applyPreset(expr: string) {
|
||||
parseCronExpression(expr);
|
||||
}
|
||||
|
||||
function parseFieldString(key: CronFieldKey, val: string) {
|
||||
parseCronPart(key, val.trim() || '*');
|
||||
}
|
||||
|
||||
function toggleCronValue(field: CronField, val: number) {
|
||||
const idx = field.values.indexOf(val);
|
||||
if (idx === -1) {
|
||||
field.values.push(val);
|
||||
} else {
|
||||
field.values.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionColor(selected: boolean) {
|
||||
return selected ? 'blue' : 'default';
|
||||
}
|
||||
|
||||
function isPresetActive(expr: string) {
|
||||
return cronExpression.value === normalizeCronExpression(expr);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', cronExpression.value);
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resetCronBuilder();
|
||||
activeTab.value = 'minute';
|
||||
if (props.value) {
|
||||
parseCronExpression(props.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="Crontab 可视化配置"
|
||||
width="720px"
|
||||
:styles="{
|
||||
container: { background: 'hsl(var(--background))' },
|
||||
}"
|
||||
@ok="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="rounded-lg border border-border bg-accent/20 p-3">
|
||||
<div class="mb-2 text-sm">快捷预设</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a-button
|
||||
v-for="preset in CRONTAB_PRESETS"
|
||||
:key="preset.value"
|
||||
size="small"
|
||||
variant="dashed"
|
||||
:color="isPresetActive(preset.value) ? 'primary' : 'default'"
|
||||
class="rounded-md"
|
||||
@click="applyPreset(preset.value)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-segmented v-model:value="activeTab" block :options="segmentOptions" />
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'minute'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.minute.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.minute.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.minute.mode === 'all'" />
|
||||
<span class="text-sm">每分钟</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.minute.mode === 'step' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.minute.mode = 'step'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.minute.mode === 'step'" />
|
||||
<span class="text-sm">每隔</span>
|
||||
<a-input-number
|
||||
v-model:value="cronBuilder.minute.step"
|
||||
:min="1"
|
||||
:max="59"
|
||||
size="small"
|
||||
class="w-16"
|
||||
@focus="cronBuilder.minute.mode = 'step'"
|
||||
/>
|
||||
<span class="text-sm">分钟</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.minute.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.minute.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.minute.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-10 gap-1 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 60"
|
||||
:key="v - 1"
|
||||
:color="getOptionColor(cronBuilder.minute.values.includes(v - 1))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.minute.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.minute, v - 1);
|
||||
"
|
||||
>
|
||||
{{ v - 1 }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'hour'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.hour.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.hour.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.hour.mode === 'all'" />
|
||||
<span class="text-sm">每小时</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.hour.mode === 'step' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.hour.mode = 'step'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.hour.mode === 'step'" />
|
||||
<span class="text-sm">每隔</span>
|
||||
<a-input-number
|
||||
v-model:value="cronBuilder.hour.step"
|
||||
:min="1"
|
||||
:max="23"
|
||||
size="small"
|
||||
class="w-16"
|
||||
@focus="cronBuilder.hour.mode = 'step'"
|
||||
/>
|
||||
<span class="text-sm">小时</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.hour.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.hour.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.hour.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-6 gap-1 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 24"
|
||||
:key="v - 1"
|
||||
:color="getOptionColor(cronBuilder.hour.values.includes(v - 1))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.hour.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.hour, v - 1);
|
||||
"
|
||||
>
|
||||
{{ v - 1 }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'day'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.day.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.day.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.day.mode === 'all'" />
|
||||
<span class="text-sm">每天</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.day.mode === 'step' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.day.mode = 'step'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.day.mode === 'step'" />
|
||||
<span class="text-sm">每隔</span>
|
||||
<a-input-number
|
||||
v-model:value="cronBuilder.day.step"
|
||||
:min="1"
|
||||
:max="31"
|
||||
size="small"
|
||||
class="w-16"
|
||||
@focus="cronBuilder.day.mode = 'step'"
|
||||
/>
|
||||
<span class="text-sm">天</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.day.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.day.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.day.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 31"
|
||||
:key="v"
|
||||
:color="getOptionColor(cronBuilder.day.values.includes(v))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.day.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.day, v);
|
||||
"
|
||||
>
|
||||
{{ v }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'month'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.month.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.month.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.month.mode === 'all'" />
|
||||
<span class="text-sm">每月</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.month.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.month.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.month.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-2 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 12"
|
||||
:key="v"
|
||||
:color="getOptionColor(cronBuilder.month.values.includes(v))"
|
||||
class="cursor-pointer select-none text-center transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.month.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.month, v);
|
||||
"
|
||||
>
|
||||
{{ v }}月
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="activeTab === 'weekday'"
|
||||
class="space-y-2 rounded-lg border border-border px-4 py-2.5"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:class="cronBuilder.weekday.mode === 'all' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.weekday.mode = 'all'"
|
||||
>
|
||||
<a-radio :checked="cronBuilder.weekday.mode === 'all'" />
|
||||
<span class="text-sm">每天</span>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer"
|
||||
:class="cronBuilder.weekday.mode === 'specific' ? '' : 'opacity-40'"
|
||||
@click="cronBuilder.weekday.mode = 'specific'"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<a-radio :checked="cronBuilder.weekday.mode === 'specific'" />
|
||||
<span class="text-sm">指定</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 pl-6">
|
||||
<a-tag
|
||||
v-for="v in 7"
|
||||
:key="v - 1"
|
||||
:color="
|
||||
getOptionColor(cronBuilder.weekday.values.includes(v - 1))
|
||||
"
|
||||
class="cursor-pointer select-none px-4 py-1 transition-all hover:opacity-80"
|
||||
@click.stop="
|
||||
cronBuilder.weekday.mode = 'specific';
|
||||
toggleCronValue(cronBuilder.weekday, v - 1);
|
||||
"
|
||||
>
|
||||
{{ WEEKDAY_LABELS[v - 1] }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 border-border">
|
||||
<div class="flex-1">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="item in segmentOptions"
|
||||
:key="item.value"
|
||||
class="flex items-center overflow-hidden rounded-md border border-input transition-colors"
|
||||
:class="{
|
||||
'border-primary shadow-sm': activeTab === item.value,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 border-r border-border px-2.5 py-1 text-sm font-medium text-muted-foreground"
|
||||
:class="{
|
||||
'text-primary': activeTab === item.value,
|
||||
}"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<a-input
|
||||
:value="cronParts[item.value as keyof typeof cronParts]"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="font-mono text-sm"
|
||||
@change="
|
||||
(e: Event) =>
|
||||
parseFieldString(
|
||||
item.value as any,
|
||||
(e.target as HTMLInputElement).value,
|
||||
)
|
||||
"
|
||||
@focus="activeTab = item.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 flex items-center overflow-hidden rounded-md border border-input"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 border-r border-border px-2.5 py-1 text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
表达式
|
||||
</span>
|
||||
<a-input
|
||||
:value="cronExpression"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="font-mono text-sm"
|
||||
@change="
|
||||
(e: Event) =>
|
||||
parseCronExpression((e.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-52 flex-shrink-0 border-l border-border pl-4">
|
||||
<div class="mb-1 text-sm font-medium text-primary">
|
||||
近五次执行时间
|
||||
</div>
|
||||
<template v-if="cronPreviewRuns.length > 0">
|
||||
<div
|
||||
v-for="(time, idx) in cronPreviewRuns"
|
||||
:key="idx"
|
||||
class="py-0.5 font-mono text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{{ dayjs(time).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-sm text-muted-foreground/60">
|
||||
无法计算运行时间
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { TaskSchedulerResult } from '#/api';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getTaskRegisteredApi } from '#/api';
|
||||
import { DictEnum, getDictOptions } from '#/utils/dict';
|
||||
|
||||
export const CRONTAB_PRESETS = [
|
||||
{ label: '每分钟', value: '* * * * *' },
|
||||
{ label: '每小时整点', value: '0 * * * *' },
|
||||
{ label: '每天午夜', value: '0 0 * * *' },
|
||||
{ label: '每天 9:00', value: '0 9 * * *' },
|
||||
{ label: '工作日 9:00', value: '0 9 * * 1-5' },
|
||||
{ label: '每15分钟', value: '*/15 * * * *' },
|
||||
{ label: '每3小时', value: '0 */3 * * *' },
|
||||
];
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '任务名称',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
|
||||
},
|
||||
fieldName: 'type',
|
||||
label: '策略类型',
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<TaskSchedulerResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'seq',
|
||||
title: $t('common.table.id'),
|
||||
type: 'seq',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '任务名称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'task',
|
||||
title: 'Celery 任务',
|
||||
minWidth: 200,
|
||||
showOverflow: true,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
title: '策略类型',
|
||||
width: 150,
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'schedule',
|
||||
title: '触发策略',
|
||||
minWidth: 150,
|
||||
slots: { default: 'schedule' },
|
||||
titleSuffix: {
|
||||
icon: 'vxe-icon-question-circle-fill',
|
||||
content:
|
||||
'Crontab 表达式:https://docs.celeryq.dev/en/latest/userguide/periodic-tasks.html#crontab-schedules',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
slots: { default: 'enabled' },
|
||||
},
|
||||
{
|
||||
field: 'total_run_count',
|
||||
title: '执行总计',
|
||||
width: 100,
|
||||
slots: { default: 'total_run_count' },
|
||||
},
|
||||
{
|
||||
field: 'last_run_time',
|
||||
title: '最近执行',
|
||||
width: 168,
|
||||
},
|
||||
{
|
||||
field: 'execute',
|
||||
title: '手动执行',
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 80,
|
||||
slots: { default: 'execute' },
|
||||
},
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'log',
|
||||
text: '日志',
|
||||
},
|
||||
'edit',
|
||||
'delete',
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createSchema(
|
||||
onOpenCrontabBuilder: () => void,
|
||||
): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: 'Interval(间隔)', value: 0 },
|
||||
// { label: 'Crontab(计划)', value: 1 },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.TASK_STRATEGY_TYPE),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
fieldName: 'type',
|
||||
formItemClass: 'col-span-2 md:col-span-2',
|
||||
label: '策略类型',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '任务名称',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
api: getTaskRegisteredApi,
|
||||
class: 'w-full',
|
||||
labelField: 'name',
|
||||
valueField: 'task',
|
||||
placeholder: 'Celery 任务名称或任务模块化字符串',
|
||||
},
|
||||
fieldName: 'task',
|
||||
label: 'Celery 任务',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'args',
|
||||
label: '位置参数',
|
||||
help: 'JSON 数组格式,例如:[1, "hello", true]',
|
||||
rules: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val || val.trim() === '') return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (!Array.isArray(parsed)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '必须是 JSON 数组格式,例如:[1, "hello", true]',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return JSON.stringify(parsed);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '无效的 JSON 格式,请输入合法的 JSON 数组',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'kwargs',
|
||||
label: '关键字参数',
|
||||
help: 'JSON 对象格式,例如:{"key": "value", "count": 10}',
|
||||
rules: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val, ctx) => {
|
||||
if (!val || val.trim() === '') return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (
|
||||
Array.isArray(parsed) ||
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '必须是 JSON 对象格式,例如:{"key": "value"}',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return JSON.stringify(parsed);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: '无效的 JSON 格式,请输入合法的 JSON 对象',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'queue',
|
||||
label: '执行队列',
|
||||
help: '将任务下发到指定队列',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'exchange',
|
||||
label: '消息交换机',
|
||||
help: '参考:https://docs.celeryq.dev/en/stable/userguide/routing.html#exchanges-queues-and-routing-keys',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'routing_key',
|
||||
label: '路由密钥',
|
||||
help: '参考:https://docs.celeryq.dev/en/stable/userguide/routing.html#exchanges-queues-and-routing-keys',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
showTime: true,
|
||||
},
|
||||
fieldName: 'start_time',
|
||||
label: '开始执行时间',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
showTime: true,
|
||||
},
|
||||
dependencies: {
|
||||
disabled: (values) => {
|
||||
return !!values.expire_seconds;
|
||||
},
|
||||
triggerFields: ['expire_seconds'],
|
||||
},
|
||||
fieldName: 'expire_time',
|
||||
label: '过期时间',
|
||||
help: '如果任务执行到该时间没有执行完,则取消执行',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
dependencies: {
|
||||
disabled: (values) => {
|
||||
return !!values.expire_time;
|
||||
},
|
||||
triggerFields: ['expire_time'],
|
||||
},
|
||||
fieldName: 'expire_seconds',
|
||||
label: '过期秒数',
|
||||
help: '如果任务执行超过该秒后没有执行完,则取消执行',
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
min: 1,
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type === 0;
|
||||
},
|
||||
required: (values) => {
|
||||
return values.type === 0;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'interval_every',
|
||||
label: '执行周期',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
options: getDictOptions(DictEnum.TASK_PERIOD_TYPE),
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type === 0;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
defaultValue: 'seconds',
|
||||
fieldName: 'interval_period',
|
||||
label: '周期类型',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type === 1;
|
||||
},
|
||||
required: (values) => {
|
||||
return values.type === 1;
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
},
|
||||
fieldName: 'crontab',
|
||||
label: '执行计划',
|
||||
rules: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) =>
|
||||
!val ||
|
||||
val.trim() === '' ||
|
||||
/^(?:[\d*/,-]+\s+){4}[\d*/,-]+$/.test(val.trim()),
|
||||
{
|
||||
message: '无效的 Crontab 表达式',
|
||||
},
|
||||
),
|
||||
help: 'Crontab 表达式:https://docs.celeryq.dev/en/latest/userguide/periodic-tasks.html#crontab-schedules',
|
||||
renderComponentContent: () => ({
|
||||
suffix: () =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
style: 'cursor: default;',
|
||||
title: '可视化配置',
|
||||
onMousedown: (e: Event) => e.preventDefault(),
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation();
|
||||
onOpenCrontabBuilder();
|
||||
},
|
||||
},
|
||||
'⚙',
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
// options: [
|
||||
// { label: $t('common.enabled'), value: true },
|
||||
// { label: $t('common.disabled'), value: false },
|
||||
// ],
|
||||
options: getDictOptions(DictEnum.SYS_CHOOSE),
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: false,
|
||||
fieldName: 'one_off',
|
||||
label: '只执行一次',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { CreateTaskSchedulerParams, TaskSchedulerResult } from '#/api';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { confirm, Page, useVbenModal, VbenButton } from '@vben/common-ui';
|
||||
import { MaterialSymbolsAdd } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
createTaskSchedulerApi,
|
||||
deleteTaskSchedulerApi,
|
||||
executeTaskSchedulerApi,
|
||||
getTaskSchedulerListApi,
|
||||
updateTaskSchedulerApi,
|
||||
updateTaskSchedulerStatusApi,
|
||||
} from '#/api';
|
||||
import { router } from '#/router';
|
||||
import { useWebSocketStore } from '#/store';
|
||||
import CronBuilder from '#/views/scheduler/manage/cron-builder.vue';
|
||||
|
||||
import { createSchema, querySchema, useColumns } from './data';
|
||||
import { getNextRuns, getScheduleLabel } from './schedule';
|
||||
|
||||
const wsStore = useWebSocketStore();
|
||||
|
||||
const taskWorkerStatus = ref<any[]>([]);
|
||||
const executeLoadingMap = ref<Record<number, boolean>>({});
|
||||
const statusLoadingMap = ref<Record<number, boolean>>({});
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
function onActionClick({
|
||||
code,
|
||||
row,
|
||||
}: OnActionClickParams<TaskSchedulerResult>) {
|
||||
switch (code) {
|
||||
case 'delete': {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确认删除此任务计划吗?',
|
||||
}).then(async () => {
|
||||
gridApi.setLoading(true);
|
||||
try {
|
||||
await deleteTaskSchedulerApi(row.id);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
gridApi.setLoading(false);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
modalApi.setData(row).open();
|
||||
break;
|
||||
}
|
||||
case 'log': {
|
||||
searchLog(row.task);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions<TaskSchedulerResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getTaskSchedulerListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const cronBuilderVisible = ref(false);
|
||||
|
||||
function openCrontabBuilder() {
|
||||
cronBuilderVisible.value = true;
|
||||
}
|
||||
|
||||
function onCronConfirm(value: string) {
|
||||
formApi.setFieldValue('crontab', value);
|
||||
}
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-1 md:grid-cols-2',
|
||||
showDefaultActions: false,
|
||||
schema: createSchema(openCrontabBuilder),
|
||||
});
|
||||
|
||||
interface formTaskSchedulerParams extends CreateTaskSchedulerParams {
|
||||
id?: number;
|
||||
}
|
||||
|
||||
const formData = ref<formTaskSchedulerParams>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['任务'])
|
||||
: $t('ui.actionTitle.create', ['任务']);
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-5/12',
|
||||
destroyOnClose: true,
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (valid) {
|
||||
modalApi.lock();
|
||||
const data = await formApi.getValues<CreateTaskSchedulerParams>();
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateTaskSchedulerApi(formData.value?.id, data)
|
||||
: createTaskSchedulerApi(data));
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
await formApi.resetForm();
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = modalApi.getData<formTaskSchedulerParams>();
|
||||
formApi.resetForm();
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
data.crontab = data.id ? data.crontab : '* * * * *';
|
||||
if (data.start_time) {
|
||||
data.start_time = dayjs(data.start_time, 'YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
if (data.expire_time) {
|
||||
data.expire_time = dayjs(data.expire_time, 'YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
formApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const executeTask = async (pk: number) => {
|
||||
executeLoadingMap.value[pk] = true;
|
||||
try {
|
||||
await executeTaskSchedulerApi(pk);
|
||||
message.success('执行成功,任务已下发');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
executeLoadingMap.value[pk] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchLog = (task: string) => {
|
||||
router.push({ path: '/scheduler/record', query: { name: task } });
|
||||
};
|
||||
|
||||
const handleStatusChange = async (
|
||||
row: TaskSchedulerResult,
|
||||
checked: boolean,
|
||||
) => {
|
||||
statusLoadingMap.value[row.id] = true;
|
||||
try {
|
||||
await updateTaskSchedulerStatusApi(row.id);
|
||||
row.enabled = checked;
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
statusLoadingMap.value[row.id] = false;
|
||||
}
|
||||
};
|
||||
|
||||
function onEnabledChange(row: TaskSchedulerResult, checked: boolean) {
|
||||
handleStatusChange(row, checked);
|
||||
}
|
||||
|
||||
let cleanupWS: (() => void) | null = null;
|
||||
const intervalId = ref<any>(null);
|
||||
const emitWS = () => {
|
||||
wsStore.emit('task_worker_status');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
cleanupWS = wsStore.on('task_worker_status', (data: any[]) => {
|
||||
taskWorkerStatus.value = data;
|
||||
});
|
||||
emitWS();
|
||||
intervalId.value = setInterval(emitWS, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanupWS) {
|
||||
cleanupWS();
|
||||
}
|
||||
if (intervalId.value) {
|
||||
clearInterval(intervalId.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton @click="() => modalApi.setData(null).open()">
|
||||
<MaterialSymbolsAdd class="size-5" />
|
||||
创建任务
|
||||
</VbenButton>
|
||||
</template>
|
||||
<template #schedule="{ row }">
|
||||
<a-popover placement="top">
|
||||
<template #content>
|
||||
<div class="text-sm">
|
||||
<div class="mb-1 font-medium">接下来 5 次运行时间:</div>
|
||||
<template v-if="getNextRuns(row).length > 0">
|
||||
<div
|
||||
v-for="(time, idx) in getNextRuns(row)"
|
||||
:key="idx"
|
||||
class="py-0.5"
|
||||
>
|
||||
{{ dayjs(time).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-gray-400">无法计算运行时间</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag class="cursor-pointer">
|
||||
{{ getScheduleLabel(row) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
<template #enabled="{ row }">
|
||||
<a-switch
|
||||
:checked="row.enabled"
|
||||
:checked-value="true"
|
||||
:un-checked-value="false"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
:loading="!!statusLoadingMap[row.id]"
|
||||
@change="onEnabledChange(row, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template #total_run_count="{ row }">
|
||||
{{ row.total_run_count }} 次
|
||||
</template>
|
||||
<template #execute="{ row }">
|
||||
<a-tooltip
|
||||
v-if="taskWorkerStatus.length === 0"
|
||||
title="任务执行服务暂不可用,请联系系统管理员"
|
||||
>
|
||||
<a-button size="small" disabled>执行</a-button>
|
||||
</a-tooltip>
|
||||
<a-button
|
||||
v-else
|
||||
size="small"
|
||||
:loading="!!executeLoadingMap[row.id]"
|
||||
@click="executeTask(row.id)"
|
||||
>
|
||||
执行
|
||||
</a-button>
|
||||
</template>
|
||||
</Grid>
|
||||
<Modal :title="modalTitle">
|
||||
<Form />
|
||||
</Modal>
|
||||
<CronBuilder
|
||||
v-model:open="cronBuilderVisible"
|
||||
:value="formApi.form?.values?.crontab"
|
||||
@confirm="onCronConfirm"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TaskSchedulerResult } from '#/api';
|
||||
|
||||
import { Cron } from 'croner';
|
||||
|
||||
const periodLabelMap: Record<string, string> = {
|
||||
days: '天',
|
||||
hours: '小时',
|
||||
minutes: '分钟',
|
||||
seconds: '秒',
|
||||
};
|
||||
|
||||
const periodToSeconds: Record<string, number> = {
|
||||
days: 86_400,
|
||||
hours: 3600,
|
||||
minutes: 60,
|
||||
seconds: 1,
|
||||
};
|
||||
|
||||
function getScheduleLabel(row: TaskSchedulerResult): string {
|
||||
if (row.type === 0) {
|
||||
const label =
|
||||
periodLabelMap[row.interval_period || 'seconds'] || row.interval_period;
|
||||
return `每 ${row.interval_every || '?'} ${label}`;
|
||||
}
|
||||
return row.crontab || '';
|
||||
}
|
||||
|
||||
function getNextRuns(row: TaskSchedulerResult): Date[] {
|
||||
try {
|
||||
if (row.type === 0) {
|
||||
const every = row.interval_every;
|
||||
const period = row.interval_period || 'seconds';
|
||||
if (!every || every <= 0) return [];
|
||||
const intervalMs = every * (periodToSeconds[period] || 1) * 1000;
|
||||
const baseTime = row.last_run_time
|
||||
? new Date(row.last_run_time)
|
||||
: new Date();
|
||||
return Array.from(
|
||||
{ length: 5 },
|
||||
(_, i) => new Date(baseTime.getTime() + intervalMs * (i + 1)),
|
||||
);
|
||||
}
|
||||
if (!row.crontab) return [];
|
||||
return new Cron(row.crontab).nextRuns(5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export { getNextRuns, getScheduleLabel };
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeGridProps } from '#/adapter/vxe-table';
|
||||
import type { TaskResult } from '#/api';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
export const querySchema: VbenFormSchema[] = [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'task_id',
|
||||
label: '任务 ID',
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '任务名称',
|
||||
},
|
||||
];
|
||||
|
||||
export function useColumns(
|
||||
onActionClick?: OnActionClickFn<TaskResult>,
|
||||
): VxeGridProps['columns'] {
|
||||
return [
|
||||
{ field: 'checkbox', type: 'checkbox', align: 'left', width: 50 },
|
||||
{
|
||||
field: 'task_id',
|
||||
title: '任务 ID',
|
||||
minWidth: 200,
|
||||
showOverflow: true,
|
||||
},
|
||||
{ field: 'name', title: '任务名称' },
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
cellRender: {
|
||||
name: 'CellTag',
|
||||
options: [
|
||||
{ color: 'success', label: 'SUCCESS', value: 'SUCCESS' },
|
||||
{ color: 'error', label: 'FAILURE', value: 'FAILURE' },
|
||||
{ color: 'processing', label: 'PENDING', value: 'PENDING' },
|
||||
{ color: 'processing', label: 'STARTED', value: 'STARTED' },
|
||||
{ color: 'warning', label: 'RETRY', value: 'RETRY' },
|
||||
{ color: 'default', label: 'REVOKED', value: 'REVOKED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'result',
|
||||
title: '结果',
|
||||
showOverflow: true,
|
||||
},
|
||||
{ field: 'retries', title: '重试次数' },
|
||||
{ field: 'date_done', title: '结束时间', width: 168 },
|
||||
{
|
||||
field: 'operation',
|
||||
title: $t('common.table.operation'),
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
attrs: {
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
options: [
|
||||
{
|
||||
code: 'details',
|
||||
text: '详情',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '@vben/common-ui';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeGridListeners,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { TaskResult } from '#/api';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import {
|
||||
confirm,
|
||||
JsonViewer,
|
||||
Page,
|
||||
useVbenDrawer,
|
||||
VbenButton,
|
||||
} from '@vben/common-ui';
|
||||
import { MaterialSymbolsDelete } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteTaskResultApi, getTaskResultListApi } from '#/api';
|
||||
|
||||
import { querySchema, useColumns } from './data';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const formOptions: VbenFormProps = {
|
||||
collapsed: true,
|
||||
showCollapseButton: true,
|
||||
submitButtonOptions: {
|
||||
content: $t('common.form.query'),
|
||||
},
|
||||
schema: querySchema,
|
||||
};
|
||||
|
||||
function onActionClick({ code, row }: OnActionClickParams<TaskResult>) {
|
||||
switch (code) {
|
||||
case 'details': {
|
||||
taskResultDetails.value = row;
|
||||
drawerApi.open();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gridOptions: VxeTableGridOptions<TaskResult> = {
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
},
|
||||
height: 'auto',
|
||||
exportConfig: {},
|
||||
printConfig: {},
|
||||
toolbarConfig: {
|
||||
export: true,
|
||||
print: true,
|
||||
refresh: true,
|
||||
refreshOptions: {
|
||||
code: 'query',
|
||||
},
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
columns: useColumns(onActionClick),
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getTaskResultListApi({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const gridEvents: VxeGridListeners<TaskResult> = {
|
||||
checkboxChange: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
checkboxAll: () => {
|
||||
const data = gridApi.grid.getCheckboxRecords(true);
|
||||
checkedRows.value = data.map((item: any) => item.id);
|
||||
},
|
||||
};
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
destroyOnClose: true,
|
||||
footer: false,
|
||||
class: 'w-2/5',
|
||||
});
|
||||
|
||||
const taskResultDetails = ref<TaskResult>();
|
||||
|
||||
function parseJsonSafe(value: any): any {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const taskResultDescItems = computed(() => {
|
||||
const d = taskResultDetails.value;
|
||||
return [
|
||||
{ key: 'task_id', label: '任务 ID', content: d?.task_id, span: 2 },
|
||||
{ key: 'name', label: '任务名称', content: d?.name },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'date_done', label: '结束时间', content: d?.date_done || 'N/A' },
|
||||
{ key: 'retries', label: '重试次数', content: d?.retries ?? 0 },
|
||||
{ key: 'worker', label: 'Worker', content: d?.worker || 'N/A', span: 2 },
|
||||
{ key: 'queue', label: '队列', content: d?.queue || 'N/A', span: 2 },
|
||||
{ key: 'args', label: '位置参数', span: 2 },
|
||||
{ key: 'kwargs', label: '关键参数', span: 2 },
|
||||
{ key: 'result', label: '执行结果', span: 2 },
|
||||
{ key: 'traceback', label: '错误回溯', span: 2 },
|
||||
];
|
||||
});
|
||||
|
||||
const checkedRows = ref<number[]>([]);
|
||||
const deleteDisable = ref<boolean>(true);
|
||||
const deleteLoading = ref<boolean>(false);
|
||||
|
||||
const deleteTaskResult = async () => {
|
||||
confirm({
|
||||
icon: 'warning',
|
||||
content: '确定删除已勾选的记录吗?',
|
||||
}).then(async () => {
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
await deleteTaskResultApi(checkedRows.value);
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
onRefresh();
|
||||
deleteDisable.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(checkedRows, () => {
|
||||
deleteDisable.value = checkedRows.value.length === 0;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query) {
|
||||
gridApi.reload({ name: route.query.name ?? undefined });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<VbenButton
|
||||
variant="destructive"
|
||||
:disabled="deleteDisable"
|
||||
:loading="deleteLoading"
|
||||
@click="deleteTaskResult"
|
||||
>
|
||||
<MaterialSymbolsDelete class="size-5" />
|
||||
删除记录
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Grid>
|
||||
<Drawer title="任务执行记录详情">
|
||||
<a-descriptions
|
||||
:styles="{ label: { color: '#6b7280' } }"
|
||||
class="ml-1"
|
||||
:column="2"
|
||||
:items="taskResultDescItems"
|
||||
>
|
||||
<template #contentRender="{ item }">
|
||||
<template v-if="item.key === 'status'">
|
||||
<a-tag
|
||||
v-if="taskResultDetails?.status === 'SUCCESS'"
|
||||
color="success"
|
||||
>
|
||||
SUCCESS
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="taskResultDetails?.status === 'FAILURE'"
|
||||
color="error"
|
||||
>
|
||||
FAILURE
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="
|
||||
taskResultDetails?.status === 'PENDING' ||
|
||||
taskResultDetails?.status === 'STARTED'
|
||||
"
|
||||
color="processing"
|
||||
>
|
||||
{{ taskResultDetails?.status }}
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="taskResultDetails?.status === 'RETRY'"
|
||||
color="warning"
|
||||
>
|
||||
RETRY
|
||||
</a-tag>
|
||||
<a-tag
|
||||
v-else-if="taskResultDetails?.status === 'REVOKED'"
|
||||
color="default"
|
||||
>
|
||||
REVOKED
|
||||
</a-tag>
|
||||
<a-tag v-else color="processing">
|
||||
{{ taskResultDetails?.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'args'">
|
||||
<JsonViewer
|
||||
v-if="taskResultDetails?.args"
|
||||
class="mr-8 w-full"
|
||||
:value="parseJsonSafe(taskResultDetails?.args)"
|
||||
:copyable="!!taskResultDetails?.args"
|
||||
boxed
|
||||
expanded
|
||||
:expand-depth="3"
|
||||
:show-array-index="false"
|
||||
@copied="message.success('已复制位置参数')"
|
||||
/>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'kwargs'">
|
||||
<JsonViewer
|
||||
v-if="taskResultDetails?.kwargs"
|
||||
class="mr-8 w-full"
|
||||
:value="parseJsonSafe(taskResultDetails?.kwargs)"
|
||||
:copyable="!!taskResultDetails?.kwargs"
|
||||
boxed
|
||||
expanded
|
||||
:expand-depth="3"
|
||||
:show-array-index="false"
|
||||
@copied="message.success('已复制关键字参数')"
|
||||
/>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'result'">
|
||||
<div v-if="taskResultDetails?.result">
|
||||
{{ taskResultDetails?.result }}
|
||||
</div>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
<template v-else-if="item.key === 'traceback'">
|
||||
<div v-if="taskResultDetails?.traceback">
|
||||
{{ taskResultDetails?.traceback }}
|
||||
</div>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-descriptions>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
Reference in New Issue
Block a user