From 8964054fd8353e20b5c7e1e3d99394d517def044 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 11 Jun 2026 08:26:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E7=B1=BB=E5=9E=8B=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/admin/task-manager.tsx | 20 ++-- .../common/admin/task-schedules.tsx | 21 ++-- frontend/lib/services/admin/admin.service.ts | 5 +- frontend/lib/services/admin/index.ts | 10 +- frontend/lib/services/admin/types.ts | 10 +- frontend/lib/services/index.ts | 2 + frontend/lib/task-param-utils.ts | 99 +++++++++++++++++++ 7 files changed, 138 insertions(+), 29 deletions(-) create mode 100644 frontend/lib/task-param-utils.ts diff --git a/frontend/components/common/admin/task-manager.tsx b/frontend/components/common/admin/task-manager.tsx index e0b07b04..187c339b 100644 --- a/frontend/components/common/admin/task-manager.tsx +++ b/frontend/components/common/admin/task-manager.tsx @@ -11,6 +11,7 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react" import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services" +import {buildTaskPayload} from "@/lib/task-param-utils" import {ErrorInline} from "@/components/layout/error" import {LoadingStateWithBorder} from "@/components/layout/loading" import {EmptyStateWithBorder} from "@/components/layout/empty" @@ -195,19 +196,16 @@ export function TaskManager() { if (userId) params.user_id = userId } - // Handle dynamic parameters + // Handle dynamic parameters — type coercion (number vs string) is + // centralised in buildTaskPayload; do not inline it here. if (targetTask?.params && targetTask.params.length > 0) { - const payloadData: Record = {} - for (const param of targetTask.params) { - const val = (paramValues[param.name] || "").trim() - if (param.required && !val) { - toast.error(`${param.label}不能为空`) - setDispatching(false) - return - } - payloadData[param.name] = val + const { payload, error } = buildTaskPayload(targetTask.params, paramValues) + if (error) { + toast.error(error) + setDispatching(false) + return } - params.payload = JSON.stringify(payloadData) + params.payload = payload ?? undefined } const taskID = await AdminService.dispatchTask(params) diff --git a/frontend/components/common/admin/task-schedules.tsx b/frontend/components/common/admin/task-schedules.tsx index 31dac431..efe4ea0f 100644 --- a/frontend/components/common/admin/task-schedules.tsx +++ b/frontend/components/common/admin/task-schedules.tsx @@ -13,6 +13,7 @@ import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/c import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react" import {AdminService, CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services" +import {buildTaskPayload} from "@/lib/task-param-utils" import {ErrorInline} from "@/components/layout/error" import {LoadingStateWithBorder} from "@/components/layout/loading" import {EmptyStateWithBorder} from "@/components/layout/empty" @@ -146,22 +147,18 @@ export function TaskSchedulesManager() { setSubmitLoading(true) const targetTask = taskTypes.find(t => t.type === selectedTaskType) - const payloadData: Record = {} + let payloadStr = '{}' - if (targetTask?.params) { - for (const param of targetTask.params) { - const val = (paramValues[param.name] || "").trim() - if (param.required && !val) { - toast.error(`${param.label}不能为空`) - setSubmitLoading(false) - return - } - payloadData[param.name] = val + if (targetTask?.params && targetTask.params.length > 0) { + const { payload, error } = buildTaskPayload(targetTask.params, paramValues) + if (error) { + toast.error(error) + setSubmitLoading(false) + return } + payloadStr = payload ?? '{}' } - const payloadStr = JSON.stringify(payloadData) - if (editingSchedule) { const req: UpdateScheduleRequest = { name, diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index 7603b797..0897639b 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -18,6 +18,7 @@ import type { SystemStatus, TaskExecution, TaskMeta, + TaskParamType, TaskTypeResponse, Template, ToggleAuthSourceRequest, @@ -187,7 +188,9 @@ export class AdminService extends BaseService { params: (item.Params || item.params || []).map(p => ({ name: p.Name || p.name || '', label: p.Label || p.label || '', - type: p.Type || p.type || '', + // Cast to TaskParamType: backend only emits 'string' | 'text' | 'number'. + // Unknown future values fall through safely to the string branch. + type: (p.Type || p.type || 'string') as TaskParamType, required: p.Required ?? p.required ?? false, placeholder: p.Placeholder || p.placeholder || '', description: p.Description || p.description || '', diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts index 9a077187..e5e85cb3 100644 --- a/frontend/lib/services/admin/index.ts +++ b/frontend/lib/services/admin/index.ts @@ -1,19 +1,19 @@ /** * 管理员服务模块 - * + * * @description * 提供系统配置、用户与任务管理功能,包括: * - 系统配置管理(创建、查询、更新、删除) * - 异步任务配置及下发 * - 用户账号状态管理 - * + * * @remarks * 所有接口都需要管理员权限 - * + * * @example * ```typescript * import { AdminService } from '@/lib/services'; - * + * * // 获取系统配置列表 * const configs = await AdminService.listSystemConfigs(); * ``` @@ -29,6 +29,8 @@ export type { AuthSourceRequest, ToggleAuthSourceRequest, TaskMeta, + TaskParam, + TaskParamType, TaskExecution, TaskExecutionStatus, ListTaskExecutionsRequest, diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 6945bdc4..889df880 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -92,13 +92,21 @@ export interface TaskTypeResponse { params?: TaskParamResponse[]; } +/** + * 任务参数的数据类型 + * - string → 单行文本输入,JSON 中序列化为 string + * - text → 多行文本输入,JSON 中序列化为 string + * - number → 数字输入,JSON 中序列化为 number(而非 string) + */ +export type TaskParamType = 'string' | 'text' | 'number'; + /** * 任务参数定义 */ export interface TaskParam { name: string; label: string; - type: string; + type: TaskParamType; required: boolean; placeholder: string; description: string; diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 757f8fbb..0f0c3926 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -99,6 +99,8 @@ export type { CreateUserRequest, UpdateSystemConfigRequest, TaskMeta, + TaskParam, + TaskParamType, TaskExecution, TaskExecutionStatus, ListTaskExecutionsRequest, diff --git a/frontend/lib/task-param-utils.ts b/frontend/lib/task-param-utils.ts new file mode 100644 index 00000000..a80eb840 --- /dev/null +++ b/frontend/lib/task-param-utils.ts @@ -0,0 +1,99 @@ +/** + * task-param-utils.ts + * + * 统一的任务参数 Payload 构建工具。 + * + * 设计原则: + * - 所有 Task Param 的类型转换和空值过滤在这里集中处理, + * 组件只负责收集原始字符串输入值。 + * - 新增参数类型时,只需在 coerceParamValue 中增加一个 case。 + * - 不引入任何框架/UI 依赖,纯工具函数。 + */ + +import type {TaskParam} from '@/lib/services/admin/types'; + +/** buildTaskPayload 的返回结构 */ +export interface BuildPayloadResult { + /** 验证通过时返回序列化好的 JSON 字符串;失败时为 null */ + payload: string | null; + /** 验证失败时返回人类可读的错误信息;成功时为 null */ + error: string | null; +} + +/** + * 根据 TaskParam 的 type 声明,将原始字符串输入值转换为正确的 JSON 值类型。 + * + * 规则: + * - 'number' → 转为 JS number;空字符串视为「未填写」,返回 undefined(由调用方决定是否跳过) + * - 'string' / 'text' → 保持字符串,原样返回 + * + * @returns 转换后的值,或 `undefined` 表示该参数应当从 payload 中省略 + */ +function coerceParamValue( + param: TaskParam, + rawValue: string, +): { value: string | number; ok: true } | { value: undefined; ok: true } | { error: string; ok: false } { + const trimmed = rawValue.trim(); + + switch (param.type) { + case 'number': { + if (trimmed === '') { + // 空值 → 省略该字段(对 omitempty 字段友好) + return { value: undefined, ok: true }; + } + const num = Number(trimmed); + if (Number.isNaN(num)) { + return { error: `「${param.label}」必须是有效的数字`, ok: false }; + } + return { value: num, ok: true }; + } + case 'string': + case 'text': + default: + // 字符串类型:空值也保留(空字符串是合法的字符串值) + return { value: trimmed, ok: true }; + } +} + +/** + * 将表单收集的原始参数值(全为 string)构建为类型正确的 JSON payload 字符串。 + * + * 行为: + * - `number` 类型参数:序列化为 JSON number,而非 JSON string。 + * - `number` 类型参数为空且非必填:从 payload 中省略该字段(不发送), + * 避免后端收到 `"target_type":""` 再尝试解析为 int 时报错。 + * - `required` 字段为空:返回 error。 + * + * @param params 任务的参数定义列表(来自 TaskMeta.params) + * @param values 表单收集的原始字符串值,key 为 param.name + * @returns BuildPayloadResult + */ +export function buildTaskPayload( + params: TaskParam[], + values: Record, +): BuildPayloadResult { + const payloadData: Record = {}; + + for (const param of params) { + const raw = values[param.name] ?? ''; + + // 必填项空值检查 + if (param.required && raw.trim() === '') { + return { payload: null, error: `「${param.label}」不能为空` }; + } + + const result = coerceParamValue(param, raw); + + if (!result.ok) { + return { payload: null, error: result.error }; + } + + if (result.value !== undefined) { + // 有值(包括空字符串)才写入 + payloadData[param.name] = result.value; + } + // undefined → 省略该字段(number 类型的空值) + } + + return { payload: JSON.stringify(payloadData), error: null }; +}