chore: vendor FBA UI 工程源码(fastapi-best-architecture-ui master,Epic #163 受控源码基础)

This commit is contained in:
2026-08-06 12:50:35 +08:00
parent 9728fa40fe
commit 88d84757ac
1166 changed files with 109805 additions and 0 deletions
@@ -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>