diff --git a/apps/web-antd/src/api/infra/job/index.ts b/apps/web-antd/src/api/infra/job/index.ts index 04848dd8b..8f1bb7d88 100644 --- a/apps/web-antd/src/api/infra/job/index.ts +++ b/apps/web-antd/src/api/infra/job/index.ts @@ -3,6 +3,23 @@ import type { PageParam, PageResult } from '@vben/request'; import { requestClient } from '#/api/request'; 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 { id?: number; @@ -16,7 +33,52 @@ export namespace InfraJobApi { monitorTimeout: number; createTime?: Date; nextTimes?: Date[]; + /** + * 处理器参数字段定义 + * 仅编辑时 get 详情返回 + */ + paramFields?: JobParamField[]; + /** + * 处理器参数形态 + * 仅编辑时 get 详情返回 + */ + paramShape?: JobParamShape; } + + /** + * 创建/修改任务提交体 + * 不含 paramFields、paramShape 等详情元数据 + */ + export type JobSavePayload = Pick< + Job, + | 'cronExpression' + | 'handlerName' + | 'handlerParam' + | 'id' + | 'monitorTimeout' + | 'name' + | 'retryCount' + | 'retryInterval' + >; +} + +/** + * 从表单值提取任务保存 payload + * 过滤 paramFields、paramShape 等只读字段 + */ +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); } /** 修改定时任务调度 */ -export function updateJob(data: InfraJobApi.Job) { +export function updateJob(data: InfraJobApi.JobSavePayload) { return requestClient.put('/infra/job/update', data); } diff --git a/apps/web-antd/src/views/infra/job/components/JobHandlerParamEditor.vue b/apps/web-antd/src/views/infra/job/components/JobHandlerParamEditor.vue new file mode 100644 index 000000000..70701ca7c --- /dev/null +++ b/apps/web-antd/src/views/infra/job/components/JobHandlerParamEditor.vue @@ -0,0 +1,314 @@ + + + + + + + + + + + + + + + + + + + + 配置 {{ index + 1 }} + + + + 删除 + + + + updateArrayItemField(item, key, value)" + @change="emitArrayValue" + /> + + + + 添加配置 + + + + + + + + + + 配置 {{ index + 1 }} + + + + 删除 + + + + + + 域名 + + handleStringItemChange(index, v)" + /> + + + + + 添加配置 + + + + diff --git a/apps/web-antd/src/views/infra/job/components/JobHandlerParamFields.vue b/apps/web-antd/src/views/infra/job/components/JobHandlerParamFields.vue new file mode 100644 index 000000000..bb8091976 --- /dev/null +++ b/apps/web-antd/src/views/infra/job/components/JobHandlerParamFields.vue @@ -0,0 +1,110 @@ + + + + + + + + {{ field.label }} + * + + handleNumberChange(field.key, v)" + /> + handleStringChange(field.key, v)" + /> + + + diff --git a/apps/web-antd/src/views/infra/job/data.ts b/apps/web-antd/src/views/infra/job/data.ts index 44d4ec2b2..4898c52e3 100644 --- a/apps/web-antd/src/views/infra/job/data.ts +++ b/apps/web-antd/src/views/infra/job/data.ts @@ -1,5 +1,6 @@ import type { VbenFormSchema } from '#/adapter/form'; import type { VxeTableGridOptions } from '#/adapter/vxe-table'; +import type { InfraJobApi } from '#/api/infra/job'; import type { DescriptionItemSchema } from '#/components/description'; import { h, markRaw } from 'vue'; @@ -13,7 +14,12 @@ import { Timeline } from 'ant-design-vue'; import { CronTab } from '#/components/cron-tab'; import { DictTag } from '#/components/dict-tag'; -/** 新增/修改的表单 */ +import JobHandlerParamEditor from './components/JobHandlerParamEditor.vue'; +import { formatHandlerParamDisplay } from './utils/job-handler-param'; + +/** + * 新增/修改的表单 + */ export function useFormSchema(): VbenFormSchema[] { return [ { @@ -49,9 +55,37 @@ export function useFormSchema(): VbenFormSchema[] { { fieldName: 'handlerParam', 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', - componentProps: { - placeholder: '请输入处理器的参数', + dependencies: { + triggerFields: [''], + show: () => false, + }, + }, + { + fieldName: 'paramShape', + component: 'Input', + dependencies: { + triggerFields: [''], + show: () => false, }, }, { @@ -207,6 +241,16 @@ export function useDetailSchema(): DescriptionItemSchema[] { { field: 'handlerParam', 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', diff --git a/apps/web-antd/src/views/infra/job/modules/form.vue b/apps/web-antd/src/views/infra/job/modules/form.vue index a66c66841..dd9ec63e3 100644 --- a/apps/web-antd/src/views/infra/job/modules/form.vue +++ b/apps/web-antd/src/views/infra/job/modules/form.vue @@ -8,11 +8,18 @@ import { useVbenModal } from '@vben/common-ui'; import { message } from 'ant-design-vue'; 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 { useFormSchema } from '../data'; +defineOptions({ name: 'InfraJobForm' }); + const emit = defineEmits(['success']); const formData = ref(); const getTitle = computed(() => { @@ -41,8 +48,10 @@ const [Modal, modalApi] = useVbenModal({ return; } modalApi.lock(); - // 提交表单 - const data = (await formApi.getValues()) as InfraJobApi.Job; + // 过滤 paramFields / paramShape 等只读字段,仅提交 JobSavePayload + const data = pickJobSavePayload( + (await formApi.getValues()) as InfraJobApi.Job, + ); try { await (formData.value?.id ? updateJob(data) : createJob(data)); // 关闭并提示 @@ -60,13 +69,22 @@ const [Modal, modalApi] = useVbenModal({ } // 加载数据 const data = modalApi.getData(); - if (!data || !data.id) { + // 新建:无 paramFields,handlerParam 为单行文本 + if (!data?.id) { + formData.value = undefined; + await formApi.resetForm(); + await formApi.setValues({ + handlerParam: '', + retryCount: 0, + retryInterval: 0, + monitorTimeout: 0, + }); return; } + // 编辑:get 详情含 paramFields + paramShape,驱动结构化参数表单 modalApi.lock(); try { formData.value = await getJob(data.id); - // 设置到 values await formApi.setValues(formData.value); } finally { modalApi.unlock(); @@ -76,7 +94,7 @@ const [Modal, modalApi] = useVbenModal({ - + diff --git a/apps/web-antd/src/views/infra/job/utils/job-handler-param.ts b/apps/web-antd/src/views/infra/job/utils/job-handler-param.ts new file mode 100644 index 000000000..7dcd8bf91 --- /dev/null +++ b/apps/web-antd/src/views/infra/job/utils/job-handler-param.ts @@ -0,0 +1,179 @@ +/** + * 任务处理器参数(handlerParam)解析与序列化工具 + * + * handlerParam 在库中存为 JSON 字符串,形态由后端 JobHandler#getParamShape() 决定: + * - object:单个 JSON 对象 + * - array:JSON 对象数组 + * - string_array:JSON 字符串数组 + */ +import type { InfraJobApi } from '#/api/infra/job'; + +export type JobParamFieldValue = number | string | undefined; + +export type JobParamFieldModel = Record; + +/** + * 判断字段是否渲染为数字输入框 + * 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, + 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; + 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, paramFields), + ) + : fallback; + } + // 兼容旧数据:单对象自动包成数组 + if (parsed && typeof parsed === 'object') { + return [ + mapObjectToParamModel(parsed as Record, 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).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; + } +}