Pre Merge pull request !358 from fan/master-job

pull/358/MERGE
fan 2026-06-07 04:59:43 +00:00 committed by Gitee
commit bc0faae81e
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
6 changed files with 738 additions and 11 deletions

View File

@ -3,6 +3,23 @@ import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export namespace InfraJobApi { export namespace InfraJobApi {
/**
*
* object / array / string_array
*/
export type JobParamShape = 'array' | 'object' | 'string_array';
/**
*
*
*/
export interface JobParamField {
key: string;
label: string;
defaultValue?: boolean | number | string;
required?: boolean;
}
/** 任务信息 */ /** 任务信息 */
export interface Job { export interface Job {
id?: number; id?: number;
@ -16,7 +33,52 @@ export namespace InfraJobApi {
monitorTimeout: number; monitorTimeout: number;
createTime?: Date; createTime?: Date;
nextTimes?: Date[]; nextTimes?: Date[];
/**
*
* get
*/
paramFields?: JobParamField[];
/**
*
* get
*/
paramShape?: JobParamShape;
} }
/**
* /
* paramFieldsparamShape
*/
export type JobSavePayload = Pick<
Job,
| 'cronExpression'
| 'handlerName'
| 'handlerParam'
| 'id'
| 'monitorTimeout'
| 'name'
| 'retryCount'
| 'retryInterval'
>;
}
/**
* payload
* paramFieldsparamShape
*/
export function pickJobSavePayload(
job: InfraJobApi.Job,
): InfraJobApi.JobSavePayload {
return {
id: job.id,
name: job.name,
handlerName: job.handlerName,
handlerParam: job.handlerParam,
cronExpression: job.cronExpression,
retryCount: job.retryCount,
retryInterval: job.retryInterval,
monitorTimeout: job.monitorTimeout,
};
} }
/** 查询任务列表 */ /** 查询任务列表 */
@ -32,12 +94,12 @@ export function getJob(id: number) {
} }
/** 新增任务 */ /** 新增任务 */
export function createJob(data: InfraJobApi.Job) { export function createJob(data: InfraJobApi.JobSavePayload) {
return requestClient.post('/infra/job/create', data); return requestClient.post('/infra/job/create', data);
} }
/** 修改定时任务调度 */ /** 修改定时任务调度 */
export function updateJob(data: InfraJobApi.Job) { export function updateJob(data: InfraJobApi.JobSavePayload) {
return requestClient.put('/infra/job/update', data); return requestClient.put('/infra/job/update', data);
} }

View File

@ -0,0 +1,314 @@
<!--
任务处理器参数编辑器Vben Form 自定义组件
- 新建单行 Input手填 JSON 或纯文本
- 编辑按后端下发的 paramShape + paramFields 渲染结构化表单
- object单组字段
- array多组配置块 FTP 列表
- string_array多行字符串如域名列表
- 对外仅暴露 handlerParam 字符串v-model:value
-->
<script lang="ts" setup>
import type {
JobParamFieldModel,
JobParamFieldValue,
} from '../utils/job-handler-param';
import type { InfraJobApi } from '#/api/infra/job';
import { computed, ref, watch } from 'vue';
import { ContentWrap, VbenButton } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Input } from 'ant-design-vue';
import {
buildDefaultParamModel,
parseArrayParamValue,
parseObjectParamValue,
parseStringArrayParamValue,
serializeStringArrayParam,
} from '../utils/job-handler-param';
import JobHandlerParamFields from './JobHandlerParamFields.vue';
defineOptions({ name: 'JobHandlerParamEditor' });
const props = withDefaults(
defineProps<{
isEdit?: boolean;
paramFields?: InfraJobApi.JobParamField[];
paramShape?: InfraJobApi.JobParamShape;
value?: string;
}>(),
{
isEdit: false,
paramFields: () => [],
paramShape: 'object',
value: '',
},
);
const emit = defineEmits<{
'update:value': [value: string];
}>();
// GET /infra/job/get
const fields = ref<InfraJobApi.JobParamField[]>([]);
// object
const formModel = ref(buildDefaultParamModel([]));
// array
const arrayItems = ref([buildDefaultParamModel([])]);
// string_array
const stringItems = ref<string[]>(['']);
const normalizedShape = computed(() => props.paramShape ?? 'object');
/**
* 编辑态且后端有字段定义 string_array时启用结构化 UI
*/
const useStructuredForm = computed(() => {
if (!props.isEdit) {
return false;
}
if (normalizedShape.value === 'string_array') {
return true;
}
return fields.value.length > 0;
});
const plainValue = computed({
get: () => props.value ?? '',
set: (val: string) => emit('update:value', val),
});
/**
* paramShape props.value 同步到本地模型并回写
*/
function syncFormModelFromValue() {
if (normalizedShape.value === 'string_array') {
stringItems.value = parseStringArrayParamValue(props.value);
emitStringArrayValue();
return;
}
if (normalizedShape.value === 'array') {
arrayItems.value = parseArrayParamValue(props.value, fields.value);
emitArrayValue();
return;
}
formModel.value = parseObjectParamValue(props.value, fields.value);
emitObjectValue();
}
function emitObjectValue() {
if (!useStructuredForm.value || normalizedShape.value !== 'object') {
return;
}
emit('update:value', JSON.stringify(formModel.value));
}
function emitArrayValue() {
if (!useStructuredForm.value || normalizedShape.value !== 'array') {
return;
}
emit('update:value', JSON.stringify(arrayItems.value));
}
function emitStringArrayValue() {
if (!useStructuredForm.value || normalizedShape.value !== 'string_array') {
return;
}
emit('update:value', serializeStringArrayParam(stringItems.value));
}
function addArrayItem() {
arrayItems.value.push(buildDefaultParamModel(fields.value));
emitArrayValue();
}
/**
* 至少保留一组空配置避免 UI 消失
*/
function removeArrayItem(index: number) {
arrayItems.value.splice(index, 1);
if (arrayItems.value.length === 0) {
arrayItems.value.push(buildDefaultParamModel(fields.value));
}
emitArrayValue();
}
function addStringItem() {
stringItems.value.push('');
emitStringArrayValue();
}
/**
* 至少保留一行空输入
*/
function removeStringItem(index: number) {
stringItems.value.splice(index, 1);
if (stringItems.value.length === 0) {
stringItems.value.push('');
}
emitStringArrayValue();
}
function handleStringItemChange(index: number, val: string) {
stringItems.value[index] = val;
emitStringArrayValue();
}
function updateObjectField(key: string, value: JobParamFieldValue | undefined) {
formModel.value[key] = value;
}
function updateArrayItemField(
item: JobParamFieldModel,
key: string,
value: JobParamFieldValue | undefined,
) {
item[key] = value;
}
watch(
() => [props.isEdit, props.paramFields, props.paramShape] as const,
([isEdit, paramFields]) => {
if (!isEdit) {
fields.value = [];
return;
}
fields.value = paramFields ?? [];
syncFormModelFromValue();
},
{ immediate: true, deep: true },
);
</script>
<template>
<div class="w-full">
<!--
新建单行文本
-->
<Input
v-if="!useStructuredForm"
v-model:value="plainValue"
placeholder="请输入处理器的参数"
allow-clear
/>
<!--
object infra 清理 Job {"retainDay":14}
-->
<div
v-else-if="normalizedShape === 'object'"
class="flex w-full flex-col gap-2"
>
<JobHandlerParamFields
:fields="fields"
:model="formModel"
@update:field="updateObjectField"
@change="emitObjectValue"
/>
</div>
<!--
array如云备份 FTP 配置列表
-->
<div
v-else-if="normalizedShape === 'array'"
class="flex w-full flex-col gap-3"
>
<ContentWrap
v-for="(item, index) in arrayItems"
:key="index"
content-class="px-4 pb-4 pt-3"
header-class="border-border px-4 py-3"
>
<template #title>
<div class="flex w-full items-center justify-between gap-2">
<span class="text-sm font-medium text-foreground">
配置 {{ index + 1 }}
</span>
<VbenButton
class="text-destructive hover:text-destructive"
size="sm"
variant="ghost"
@click="removeArrayItem(index)"
>
<IconifyIcon icon="lucide:trash-2" class="mr-1" />
删除
</VbenButton>
</div>
</template>
<JobHandlerParamFields
:fields="fields"
:model="item"
:number-min="0"
@update:field="(key, value) => updateArrayItemField(item, key, value)"
@change="emitArrayValue"
/>
</ContentWrap>
<VbenButton
class="w-full border-dashed border-border"
variant="outline"
@click="addArrayItem"
>
<IconifyIcon icon="lucide:plus" class="mr-1" />
添加配置
</VbenButton>
</div>
<!--
string_array如活动日志域名列表 ["a.com"]
-->
<div
v-else-if="normalizedShape === 'string_array'"
class="flex w-full flex-col gap-3"
>
<ContentWrap
v-for="(item, index) in stringItems"
:key="index"
content-class="px-4 pb-4 pt-3"
header-class="border-border px-4 py-3"
>
<template #title>
<div class="flex w-full items-center justify-between gap-2">
<span class="text-sm font-medium text-foreground">
配置 {{ index + 1 }}
</span>
<VbenButton
class="text-destructive hover:text-destructive"
size="sm"
variant="ghost"
@click="removeStringItem(index)"
>
<IconifyIcon icon="lucide:trash-2" class="mr-1" />
删除
</VbenButton>
</div>
</template>
<div class="flex w-full items-center gap-2">
<span
class="w-[120px] shrink-0 text-right text-sm text-muted-foreground"
>
域名
</span>
<Input
:value="item"
class="min-w-0 flex-1"
placeholder="请输入域名"
@update:value="(v) => handleStringItemChange(index, v)"
/>
</div>
</ContentWrap>
<VbenButton
class="w-full border-dashed border-border"
variant="outline"
@click="addStringItem"
>
<IconifyIcon icon="lucide:plus" class="mr-1" />
添加配置
</VbenButton>
</div>
</div>
</template>

View File

@ -0,0 +1,110 @@
<!--
处理器参数字段行label 120px+ 输入右
字段类型由 defaultValue 推断number InputNumber其余 Input
-->
<script lang="ts" setup>
import type {
JobParamFieldModel,
JobParamFieldValue,
} from '../utils/job-handler-param';
import type { InfraJobApi } from '#/api/infra/job';
import { Input, InputNumber } from 'ant-design-vue';
import { isNumberParamField } from '../utils/job-handler-param';
defineOptions({ name: 'JobHandlerParamFields' });
const props = defineProps<{
/**
* 后端 JobHandler#getParamFields() 下发的字段定义
*/
fields: InfraJobApi.JobParamField[];
/**
* 当前字段值模型只读变更通过 update:field 回传
*/
model: JobParamFieldModel;
/**
* 数字字段最小值
* array 模式 FTP 端口等可为 0
*/
numberMin?: number;
}>();
const emit = defineEmits<{
change: [];
'update:field': [key: string, value: JobParamFieldValue | undefined];
}>();
function toNumberValue(
val: JobParamFieldValue | undefined,
): number | undefined {
if (val === undefined) {
return undefined;
}
const num = typeof val === 'number' ? val : Number(String(val));
return Number.isNaN(num) ? undefined : num;
}
function getNumberFieldValue(key: string) {
return toNumberValue(props.model[key]);
}
function getStringFieldValue(key: string) {
const val = props.model[key];
if (val === undefined || val === null) {
return '';
}
return String(val);
}
function handleNumberChange(
key: string,
val: null | number | string | undefined,
) {
let value: JobParamFieldValue | undefined;
if (val === null || val === undefined || val === '') {
value = undefined;
} else {
const num = typeof val === 'number' ? val : Number(val);
value = Number.isNaN(num) ? undefined : num;
}
emit('update:field', key, value);
emit('change');
}
function handleStringChange(key: string, val: string) {
emit('update:field', key, val);
emit('change');
}
</script>
<template>
<div class="flex w-full flex-col gap-2">
<div
v-for="field in fields"
:key="field.key"
class="flex w-full items-center gap-2"
>
<span class="w-[120px] shrink-0 text-right text-sm text-muted-foreground">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</span>
<InputNumber
v-if="isNumberParamField(field)"
:value="getNumberFieldValue(field.key)"
class="min-w-0 flex-1"
:min="numberMin ?? 1"
@update:value="(v) => handleNumberChange(field.key, v)"
/>
<Input
v-else
:value="getStringFieldValue(field.key)"
class="min-w-0 flex-1"
allow-clear
@update:value="(v) => handleStringChange(field.key, v)"
/>
</div>
</div>
</template>

View File

@ -1,5 +1,6 @@
import type { VbenFormSchema } from '#/adapter/form'; import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { InfraJobApi } from '#/api/infra/job';
import type { DescriptionItemSchema } from '#/components/description'; import type { DescriptionItemSchema } from '#/components/description';
import { h, markRaw } from 'vue'; import { h, markRaw } from 'vue';
@ -13,7 +14,12 @@ import { Timeline } from 'ant-design-vue';
import { CronTab } from '#/components/cron-tab'; import { CronTab } from '#/components/cron-tab';
import { DictTag } from '#/components/dict-tag'; import { DictTag } from '#/components/dict-tag';
/** 新增/修改的表单 */ import JobHandlerParamEditor from './components/JobHandlerParamEditor.vue';
import { formatHandlerParamDisplay } from './utils/job-handler-param';
/**
* /
*/
export function useFormSchema(): VbenFormSchema[] { export function useFormSchema(): VbenFormSchema[] {
return [ return [
{ {
@ -49,9 +55,37 @@ export function useFormSchema(): VbenFormSchema[] {
{ {
fieldName: 'handlerParam', fieldName: 'handlerParam',
label: '处理器的参数', label: '处理器的参数',
// 自定义组件:新建单行输入,编辑结构化(见 JobHandlerParamEditor
component: markRaw(JobHandlerParamEditor),
modelPropName: 'value',
formItemClass: 'col-span-2 items-start',
help: '新建时手填 JSON 或文本;编辑时按处理器定义展示结构化表单',
dependencies: {
triggerFields: ['id', 'handlerName', 'paramFields', 'paramShape'],
componentProps: (values) => ({
isEdit: !!values.id,
paramFields: (values.paramFields ??
[]) as InfraJobApi.JobParamField[],
paramShape: (values.paramShape ??
'object') as InfraJobApi.JobParamShape,
}),
},
},
// 详情元数据:仅编辑时 get 返回,供 handlerParam 组件读取,提交时需过滤
{
fieldName: 'paramFields',
component: 'Input', component: 'Input',
componentProps: { dependencies: {
placeholder: '请输入处理器的参数', triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'paramShape',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
}, },
}, },
{ {
@ -207,6 +241,16 @@ export function useDetailSchema(): DescriptionItemSchema[] {
{ {
field: 'handlerParam', field: 'handlerParam',
label: '处理器的参数', label: '处理器的参数',
render: (val) => {
return h(
'pre',
{
class:
'mb-0 max-h-[240px] overflow-auto whitespace-pre-wrap break-all rounded-md border border-border bg-muted p-2 text-xs text-foreground',
},
formatHandlerParamDisplay(val as string),
);
},
}, },
{ {
field: 'cronExpression', field: 'cronExpression',

View File

@ -8,11 +8,18 @@ import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { createJob, getJob, updateJob } from '#/api/infra/job'; import {
createJob,
getJob,
pickJobSavePayload,
updateJob,
} from '#/api/infra/job';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { useFormSchema } from '../data'; import { useFormSchema } from '../data';
defineOptions({ name: 'InfraJobForm' });
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const formData = ref<InfraJobApi.Job>(); const formData = ref<InfraJobApi.Job>();
const getTitle = computed(() => { const getTitle = computed(() => {
@ -41,8 +48,10 @@ const [Modal, modalApi] = useVbenModal({
return; return;
} }
modalApi.lock(); modalApi.lock();
// // paramFields / paramShape JobSavePayload
const data = (await formApi.getValues()) as InfraJobApi.Job; const data = pickJobSavePayload(
(await formApi.getValues()) as InfraJobApi.Job,
);
try { try {
await (formData.value?.id ? updateJob(data) : createJob(data)); await (formData.value?.id ? updateJob(data) : createJob(data));
// //
@ -60,13 +69,22 @@ const [Modal, modalApi] = useVbenModal({
} }
// //
const data = modalApi.getData<InfraJobApi.Job>(); const data = modalApi.getData<InfraJobApi.Job>();
if (!data || !data.id) { // paramFieldshandlerParam
if (!data?.id) {
formData.value = undefined;
await formApi.resetForm();
await formApi.setValues({
handlerParam: '',
retryCount: 0,
retryInterval: 0,
monitorTimeout: 0,
});
return; return;
} }
// get paramFields + paramShape
modalApi.lock(); modalApi.lock();
try { try {
formData.value = await getJob(data.id); formData.value = await getJob(data.id);
// values
await formApi.setValues(formData.value); await formApi.setValues(formData.value);
} finally { } finally {
modalApi.unlock(); modalApi.unlock();
@ -76,7 +94,7 @@ const [Modal, modalApi] = useVbenModal({
</script> </script>
<template> <template>
<Modal :title="getTitle" class="w-2/5"> <Modal :title="getTitle" class="w-3/5">
<Form class="mx-4" /> <Form class="mx-4" />
</Modal> </Modal>
</template> </template>

View File

@ -0,0 +1,179 @@
/**
* handlerParam
*
* handlerParam JSON JobHandler#getParamShape()
* - object JSON
* - arrayJSON
* - string_arrayJSON
*/
import type { InfraJobApi } from '#/api/infra/job';
export type JobParamFieldValue = number | string | undefined;
export type JobParamFieldModel = Record<string, JobParamFieldValue>;
/**
*
* defaultValue number 使 InputNumber
*/
export function isNumberParamField(field: InfraJobApi.JobParamField) {
return typeof field.defaultValue === 'number';
}
/**
*
* defaultValue
*/
export function buildDefaultParamModel(
paramFields: InfraJobApi.JobParamField[],
): JobParamFieldModel {
const model: JobParamFieldModel = {};
for (const field of paramFields) {
const defaultValue = field.defaultValue;
model[field.key] =
typeof defaultValue === 'number' || typeof defaultValue === 'string'
? defaultValue
: undefined;
}
return model;
}
/**
* JSON
* number / string
*/
export function mapObjectToParamModel(
obj: Record<string, unknown>,
paramFields: InfraJobApi.JobParamField[],
): JobParamFieldModel {
const model = buildDefaultParamModel(paramFields);
for (const field of paramFields) {
const raw = obj[field.key];
if (raw === undefined || raw === null) {
continue;
}
if (isNumberParamField(field)) {
const num = Number(raw);
if (!Number.isNaN(num)) {
model[field.key] = num;
}
} else {
model[field.key] = String(raw);
}
}
return model;
}
/**
* object
* handlerParam
*/
export function parseObjectParamValue(
value: string | undefined,
paramFields: InfraJobApi.JobParamField[],
): JobParamFieldModel {
const model = buildDefaultParamModel(paramFields);
if (!value?.trim()) {
return model;
}
try {
const parsed = JSON.parse(value) as Record<string, unknown>;
return mapObjectToParamModel(parsed, paramFields);
} catch {
return model;
}
}
/**
* array
* handlerParam
*
*/
export function parseArrayParamValue(
value: string | undefined,
paramFields: InfraJobApi.JobParamField[],
): JobParamFieldModel[] {
const fallback = [buildDefaultParamModel(paramFields)];
if (!value?.trim()) {
return fallback;
}
try {
const parsed: unknown = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.length > 0
? parsed.map((item) =>
mapObjectToParamModel(item as Record<string, unknown>, paramFields),
)
: fallback;
}
// 兼容旧数据:单对象自动包成数组
if (parsed && typeof parsed === 'object') {
return [
mapObjectToParamModel(parsed as Record<string, unknown>, paramFields),
];
}
} catch {
// 非法 JSON回退默认一组空配置
}
return fallback;
}
/**
* string_array
* handlerParam
* extraDomains
*/
export function parseStringArrayParamValue(
value: string | undefined,
): string[] {
const fallback = [''];
if (!value?.trim()) {
return fallback;
}
try {
const parsed: unknown = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.length > 0 ? parsed.map(String) : fallback;
}
// 兼容旧 object 形态extraDomains 逗号分隔
if (parsed && typeof parsed === 'object') {
const extraDomains = (parsed as Record<string, unknown>).extraDomains;
if (typeof extraDomains === 'string' && extraDomains.trim()) {
const domains = extraDomains
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return domains.length > 0 ? domains : fallback;
}
}
} catch {
// 非法 JSON回退一行空输入
}
return fallback;
}
/**
* string_array
* JSON
*/
export function serializeStringArrayParam(items: string[]) {
const values = items
.map((item) => item.trim())
.filter((item) => item.length > 0);
return JSON.stringify(values);
}
/**
* / handlerParam
* JSON
*/
export function formatHandlerParamDisplay(val?: string) {
if (!val?.trim()) {
return '无';
}
try {
return JSON.stringify(JSON.parse(val), null, 2);
} catch {
return val;
}
}