From 2bd79533138bc7cc5c633dc51d100572afc281ac Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 22:31:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20mock/=E5=89=8D=E7=AB=AF=20Worker=20?= =?UTF-8?q?=E4=B8=8E=E5=A4=87=E6=B3=A8=E6=A0=A1=E9=AA=8C=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88Codex=20gpt-6-astra=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/task_templates.go | 4 +- server/task_templates.go | 3 + web/src/app/(main)/function/tasks/page.tsx | 8 +- web/src/components/task-template-controls.tsx | 16 ++- web/src/lib/mock/handler.ts | 112 ++++++++++++++---- web/src/lib/types.ts | 12 ++ 6 files changed, 126 insertions(+), 29 deletions(-) diff --git a/db/task_templates.go b/db/task_templates.go index 05b2904..5401139 100644 --- a/db/task_templates.go +++ b/db/task_templates.go @@ -23,8 +23,8 @@ var ( ErrTaskTemplateNotFound = errors.New("task template not found") ) -// TaskTemplate is a reusable task preset (description/goal + optional category -// and task-level intercept/allow rules). +// TaskTemplate is a reusable task preset (description/goal + optional remark, +// worker count, category, and task-level intercept/allow rules). type TaskTemplate struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/server/task_templates.go b/server/task_templates.go index d12d382..f94de47 100644 --- a/server/task_templates.go +++ b/server/task_templates.go @@ -78,6 +78,9 @@ func validateTaskTemplateRequest(req taskTemplateRequest) error { value = strings.Join(strings.Fields(value), " ") } if utf8.RuneCountInString(value) > check.limit { + if check.name == "remark" { + return fmt.Errorf("备注最多 %d 个字符", check.limit) + } return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit) } } diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index b0dc655..3737d6d 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -137,7 +137,7 @@ import type { TaskCategory, TaskStatus, } from "@/lib/types"; -import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT } from "@/lib/types"; +import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT, parseWorkerCountInput } from "@/lib/types"; import { cn } from "@/lib/utils"; // fmtBytes renders a human file size for the upload manifest. @@ -3184,11 +3184,15 @@ function CreateTaskSheet({ toast.error(`最多关联 ${MAX_SOURCE_TASKS} 个来源任务`); return; } + const workerCountNum = parseWorkerCountInput(workerCount); + if (workerCountNum == null) { + toast.error(`Worker 运行数量必须是 0-${MAX_TASK_WORKER_COUNT} 的整数`); + return; + } setCreating(true); try { const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60; const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致 - const workerCountNum = Math.min(MAX_TASK_WORKER_COUNT, Math.max(0, Math.floor(Number(workerCount) || 0))); await api.createTask({ name: name.trim(), remark: remark.trim(), diff --git a/web/src/components/task-template-controls.tsx b/web/src/components/task-template-controls.tsx index 0d50a1b..5d286f0 100644 --- a/web/src/components/task-template-controls.tsx +++ b/web/src/components/task-template-controls.tsx @@ -33,7 +33,14 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; -import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT, type AssetInterceptRuleInput, type TaskCategory, type TaskTemplate } from "@/lib/types"; +import { + MAX_TASK_REMARK_RUNES, + MAX_TASK_WORKER_COUNT, + parseWorkerCountInput, + type AssetInterceptRuleInput, + type TaskCategory, + type TaskTemplate, +} from "@/lib/types"; import { cn } from "@/lib/utils"; interface TemplateDraft { @@ -149,13 +156,18 @@ function TaskTemplateManager({ }; async function save() { + const workerCount = parseWorkerCountInput(draft.workerCount); + if (workerCount == null) { + toast.error(`Worker 运行数量必须是 0-${MAX_TASK_WORKER_COUNT} 的整数`); + return; + } const input = { name: draft.name.trim(), description: draft.description.trim(), goal: draft.goal.trim(), remark: draft.remark.trim(), category_id: draft.categoryID, - worker_count: Math.min(MAX_TASK_WORKER_COUNT, Math.max(0, Math.floor(Number(draft.workerCount) || 0))), + worker_count: workerCount, intercept_rules: draft.interceptRules .map((r) => ({ ...r, pattern: r.pattern.trim() })) .filter((r) => r.pattern !== ""), diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 3fe33d5..c227b79 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -1000,7 +1000,9 @@ function parseBody(body?: BodyInit | null): Record { } function mockRemark(value: unknown): string { - const remark = typeof value === "string" ? value.trim() : ""; + if (value === undefined || value === null) return ""; + if (typeof value !== "string") throw new Error("备注必须是字符串"); + const remark = value.trim(); if (Array.from(remark).length > MAX_TASK_REMARK_RUNES) { throw new Error(`备注最多 ${MAX_TASK_REMARK_RUNES} 个字符`); } @@ -1008,10 +1010,27 @@ function mockRemark(value: unknown): string { } function mockWorkerCount(value: unknown): number { - if (typeof value !== "number") return 0; + if (value === undefined || value === null) return 0; + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error("Worker 运行数量必须是整数"); + } if (value < 0) throw new Error("Worker 运行数量不能为负数"); if (value > MAX_TASK_WORKER_COUNT) throw new Error(`Worker 运行数量最多 ${MAX_TASK_WORKER_COUNT}`); - return Math.floor(value); + return value; +} + +function mockStringField(value: unknown, label: string): string { + if (value === undefined || value === null) return ""; + if (typeof value !== "string") throw new Error(`${label} 必须是字符串`); + return value; +} + +function mockIntegerField(value: unknown, label: string): number | null { + if (value === undefined || value === null) return null; + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error(`${label} 必须是整数`); + } + return value; } export async function mockHandle(method: string, rawPath: string, body?: BodyInit | null): Promise { @@ -1189,20 +1208,23 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])]; const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])]; const companyIDs = [...new Set((b.company_ids as number[] | undefined) ?? [])]; + const name = mockStringField(b.name, "任务名称"); + const description = mockStringField(b.description, "任务描述"); + const goal = mockStringField(b.goal, "任务目标"); if (companyIDs.some((companyID) => !mockCompanies.some((company) => company.id === companyID))) { throw new Error("关联企业不存在或无效"); } - const categoryID = typeof b.category_id === "number" ? b.category_id : undefined; + const categoryID = mockIntegerField(b.category_id, "category_id") ?? undefined; const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID); if (categoryID !== undefined && !category) throw new Error("任务分类不存在"); const created: Task = { id, - name: String(b.name ?? ""), + name, remark: mockRemark(b.remark), category_id: category?.id, category_name: category?.name, - description: String(b.description ?? "新任务"), - goal: String(b.goal ?? ""), + description: description || "新任务", + goal, worker_count: mockWorkerCount(b.worker_count), status: "created", created_at: now.toISOString(), @@ -1318,9 +1340,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/task-templates" && m === "GET") return { templates: mockTaskTemplates }; if (path === "/task-templates" && m === "POST") { const now = new Date().toISOString(); - const name = normalizedTemplateName(b.name); - const description = String(b.description ?? "").trim(); - const goal = String(b.goal ?? "").trim(); + const name = normalizedTemplateName(mockStringField(b.name, "模板名称")); + const description = mockStringField(b.description, "模板描述").trim(); + const goal = mockStringField(b.goal, "模板目标").trim(); if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标"); if ( mockTaskTemplates.some((template) => normalizedTemplateName(template.name).toLowerCase() === name.toLowerCase()) @@ -1328,13 +1350,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re throw new Error("模板名称已存在"); } const nextID = mockTaskTemplates.reduce((max, template) => Math.max(max, template.id), 0) + 1; + const categoryID = mockIntegerField(b.category_id, "category_id"); + if (b.intercept_rules !== undefined && b.intercept_rules !== null && !Array.isArray(b.intercept_rules)) { + throw new Error("intercept_rules 必须是数组"); + } const created: TaskTemplate = { id: nextID, name, description, goal, remark: mockRemark(b.remark), - category_id: typeof b.category_id === "number" ? b.category_id : null, + category_id: categoryID, intercept_rules: Array.isArray(b.intercept_rules) ? b.intercept_rules : [], worker_count: mockWorkerCount(b.worker_count), created_at: now, @@ -1346,9 +1372,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (seg[0] === "task-templates" && seg.length === 2 && m === "PATCH") { const template = mockTaskTemplates.find((item) => item.id === Number(seg[1])); if (!template) return {}; - const name = typeof b.name === "string" ? normalizedTemplateName(b.name) : template.name; - const description = typeof b.description === "string" ? b.description.trim() : template.description; - const goal = typeof b.goal === "string" ? b.goal.trim() : template.goal; + const has = (key: string) => Object.prototype.hasOwnProperty.call(b, key); + const nameValue = has("name") ? mockStringField(b.name, "模板名称") : undefined; + const descriptionValue = has("description") ? mockStringField(b.description, "模板描述") : undefined; + const goalValue = has("goal") ? mockStringField(b.goal, "模板目标") : undefined; + const categoryID = has("category_id") ? mockIntegerField(b.category_id, "category_id") : template.category_id; + if (has("intercept_rules") && b.intercept_rules !== null && !Array.isArray(b.intercept_rules)) { + throw new Error("intercept_rules 必须是数组"); + } + const name = nameValue === undefined || b.name === null ? template.name : normalizedTemplateName(nameValue); + const description = descriptionValue === undefined || b.description === null ? template.description : descriptionValue.trim(); + const goal = goalValue === undefined || b.goal === null ? template.goal : goalValue.trim(); if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标"); if ( mockTaskTemplates.some( @@ -1357,14 +1391,24 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re ) { throw new Error("模板名称已存在"); } - const remark = typeof b.remark === "string" ? mockRemark(b.remark) : template.remark; - const workerCount = typeof b.worker_count === "number" ? mockWorkerCount(b.worker_count) : template.worker_count; + const remark = has("remark") ? mockRemark(b.remark) : template.remark; + const workerCount = has("worker_count") ? mockWorkerCount(b.worker_count) : template.worker_count; + const hasName = has("name") && b.name !== null; + const hasDescription = has("description") && b.description !== null; + const hasGoal = has("goal") && b.goal !== null; + const hasCategory = has("category_id"); + const hasRules = has("intercept_rules"); + const hasRemark = has("remark"); + const hasWorkerCount = has("worker_count"); + if (!hasName && !hasDescription && !hasGoal && !hasCategory && !hasRules && !hasRemark && !hasWorkerCount) { + throw new Error("至少需要提供 name、description、goal、remark、category_id、intercept_rules 或 worker_count"); + } template.name = name; template.description = description; template.goal = goal; template.remark = remark; - if ("category_id" in b) template.category_id = typeof b.category_id === "number" ? b.category_id : null; - if (Array.isArray(b.intercept_rules)) template.intercept_rules = b.intercept_rules; + if (hasCategory) template.category_id = categoryID; + if (hasRules) template.intercept_rules = Array.isArray(b.intercept_rules) ? b.intercept_rules : []; template.worker_count = workerCount; template.updated_at = new Date().toISOString(); mockTaskTemplates.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.id - a.id); @@ -1384,12 +1428,34 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") { const task = mockTasks.find((item) => item.id === seg[1]); if (!task) throw new Error("任务不存在"); - if (typeof b.name === "string") task.name = b.name.trim(); - if (typeof b.remark === "string") task.remark = b.remark.trim(); - if (typeof b.pinned === "boolean") { - task.pinned = b.pinned; - task.pinned_at = b.pinned ? (task.pinned_at ?? new Date().toISOString()) : null; + const has = (key: string) => Object.prototype.hasOwnProperty.call(b, key); + if (has("name") && b.name !== null && typeof b.name !== "string") throw new Error("任务名称必须是字符串"); + if (has("remark") && b.remark !== null && typeof b.remark !== "string") throw new Error("备注必须是字符串"); + if (has("pinned") && b.pinned !== null && typeof b.pinned !== "boolean") throw new Error("pinned 必须是布尔值"); + const namePresent = has("name") && b.name !== null; + const remarkPresent = has("remark") && b.remark !== null; + const pinnedPresent = has("pinned") && b.pinned !== null; + if (!namePresent && !remarkPresent && !pinnedPresent) throw new Error("至少需要提供 name、pinned 或 remark"); + let nextName = task.name; + let nextRemark = task.remark; + let nextPinned = task.pinned; + let nextPinnedAt = task.pinned_at; + if (namePresent) { + const name = (b.name as string).trim(); + if (!name) throw new Error("任务名称不能为空"); + if (Array.from(name).length > 200) throw new Error("任务名称最多 200 个字符"); + nextName = name; } + if (remarkPresent) nextRemark = mockRemark(b.remark); + if (pinnedPresent) { + const pinned = b.pinned as boolean; + nextPinned = pinned; + nextPinnedAt = pinned ? (task.pinned_at ?? new Date().toISOString()) : null; + } + task.name = nextName; + task.remark = nextRemark; + task.pinned = nextPinned; + task.pinned_at = nextPinnedAt; sortMockTasks(); return structuredClone(task); } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 7d7c094..f91ca36 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -8,6 +8,18 @@ export type EngineMode = "exploring" | "paused" | "stalled" | "idle"; export const MAX_TASK_REMARK_RUNES = 2000; export const MAX_TASK_WORKER_COUNT = 50; +// Parse the optional worker-count input without silently changing invalid values. +// An empty field means 0 (use the global default); every other value must be an +// integer in the backend's accepted range. +export function parseWorkerCountInput(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") return 0; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return null; + if (parsed < 0 || parsed > MAX_TASK_WORKER_COUNT) return null; + return parsed; +} + export interface Task { distinct_findings?: FindingDistinctStats; id: string;