"use client"; import * as React from "react"; import Link from "next/link"; <<<<<<< Updated upstream ======= import { ChevronRightIcon, EyeIcon, Loader2Icon, PaperclipIcon, PauseIcon, PlayIcon, PlusIcon, SearchIcon, SlidersHorizontalIcon, StarIcon, Trash2Icon, XIcon, } from "lucide-react"; >>>>>>> Stashed changes import { toast } from "sonner"; import { PlusIcon, Trash2Icon, ArrowRightIcon, StarIcon, SearchIcon, XIcon, ChevronRightIcon, PaperclipIcon, Loader2Icon, } from "lucide-react"; import { StatusBadge } from "@/components/status-badge"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { TablePagination } from "@/components/table-pagination"; import { api } from "@/lib/api"; import type { Task, TaskStatus, LLMProfile, ChatAttachment } from "@/lib/types"; // fmtBytes renders a human file size for the upload manifest. function fmtBytes(n: number): string { if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`; return `${n} B`; } // UPLOAD_MARKER labels the auto-appended block of uploaded-file paths inside the task // description, so re-uploads append under the same block instead of adding a new header. const UPLOAD_MARKER = "【上传文件(绝对路径)】"; // appendUploads folds newly-uploaded files' ABSOLUTE paths into the description as a // Read/Bash-friendly manifest — the worker opens them by path. Keeps one marked block: // first upload adds the header, later uploads append bullets under it. function appendUploads(desc: string, atts: ChatAttachment[]): string { const bullets = atts.map((a) => `- ${a.abs ?? a.path}(${fmtBytes(a.size)})`).join("\n"); if (desc.includes(UPLOAD_MARKER)) { return `${desc.replace(/\s*$/, "")}\n${bullets}\n`; } const head = desc.trim() ? `${desc.replace(/\s*$/, "")}\n\n` : ""; return `${head}${UPLOAD_MARKER} worker 可用 Read/Bash 按路径打开:\n${bullets}\n`; } // ACTIVE_PROFILE is the sentinel Select value for "use the global active profile". const ACTIVE_PROFILE = "__active__"; // POLL_MS is the task-list refresh interval. Task state moves on the server (planner / // worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化. const POLL_MS = 10_000; // fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M). function fmtTokens(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M"; if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k"; return String(n); } // fmtDuration renders a run duration in seconds as a compact human string. function fmtDuration(sec: number): string { if (sec <= 0) return "—"; const d = Math.floor(sec / 86400); const h = Math.floor((sec % 86400) / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } // taskDuration is the task's run duration in seconds: created → now while running, // created → completion for a finished task, else created → last activity. 0 when it // never ran (no activity yet). function taskDuration(task: Task, nowSec: number): number { const start = task.created_unix ?? 0; if (!start) return 0; const end = task.status === "running" ? nowSec : task.completed_unix && task.completed_unix > 0 ? task.completed_unix : task.last_activity_unix ?? 0; return end > start ? end - start : 0; } // fmtDateTime renders a unix-seconds timestamp as a compact local date-time // (MM-DD HH:mm), or "—" when unset. function fmtDateTime(unix?: number): string { if (!unix || unix <= 0) return "—"; const d = new Date(unix * 1000); const p = (n: number) => String(n).padStart(2, "0"); return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [ { value: "created", label: "已创建" }, { value: "running", label: "运行中" }, { value: "paused", label: "已暂停" }, { value: "done", label: "已完成" }, { value: "failed", label: "失败" }, { value: "timeout", label: "已超时" }, ]; // 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致 // (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断, // 两边不会出现一个可点、另一个不可点的分歧。 const PAUSABLE_STATUSES = new Set(["created", "queued", "running"]); function taskControlAction(status: TaskStatus): "pause" | "resume" | null { if (status === "paused") return "resume"; if (PAUSABLE_STATUSES.has(status)) return "pause"; return null; } export default function TasksPage() { const [tasks, setTasks] = React.useState([]); const [query, setQuery] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(20); const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000)); const filtered = React.useMemo(() => { const q = query.trim().toLowerCase(); return tasks.filter((t) => { if (statusFilter !== "all" && t.status !== statusFilter) return false; if (!q) return true; return ( <<<<<<< Updated upstream ======= (t.name ?? "").toLowerCase().includes(q) || >>>>>>> Stashed changes t.description.toLowerCase().includes(q) || t.goal.toLowerCase().includes(q) || t.id.toLowerCase().includes(q) ); }); }, [tasks, query, statusFilter]); // reset to page 1 whenever filters change React.useEffect(() => { setPage(1); }, [query, statusFilter]); const paginated = React.useMemo( () => filtered.slice((page - 1) * pageSize, page * pageSize), [filtered, page, pageSize], ); // lastRef holds the previous poll's serialized payload: the list is re-fetched every // POLL_MS but usually comes back unchanged, and setTasks on an identical payload would // re-render the whole page for nothing. Bail out when it matches. const lastRef = React.useRef(""); const load = React.useCallback(() => { api.tasks() .then((r) => { const next = r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t)); const sig = JSON.stringify(next); if (sig === lastRef.current) return; lastRef.current = sig; setTasks(next); }) .catch(() => {}); }, []); React.useEffect(() => { load(); const i = setInterval(load, POLL_MS); return () => clearInterval(i); }, [load]); // 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白 // 重渲染整张表。 const hasRunning = React.useMemo(() => tasks.some((t) => t.status === "running"), [tasks]); // tick every second so running tasks' 运行时长 counts up live. React.useEffect(() => { if (!hasRunning) return; setNowSec(Math.floor(Date.now() / 1000)); const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000); return () => clearInterval(i); }, [hasRunning]); <<<<<<< Updated upstream const deleteTask = React.useCallback(async (id: string) => { try { await api.deleteTask(id); toast.success("任务已删除(全局资产图保留)"); load(); } catch (e) { toast.error("删除失败:" + (e as Error).message); } }, [load]); ======= const deleteTask = React.useCallback( async (id: string, options: DeleteTaskOptions) => { try { const result = await api.deleteTask(id, options); if (result.cleanup_warning) { toast.warning(`${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`); } else { toast.success(deleteSummary(result)); } load(); } catch (e) { toast.error("删除失败:" + (e as Error).message); throw e; } }, [load], ); // controlTask 是行内暂停/继续:批量走 controlTasksBatch,单行走单任务接口,省去 // 「先勾选再点批量」。清空 lastRef 让下一次轮询即使负载相同也照单接收,否则状态 // 回写会被去重挡掉、按钮看起来没反应。 const controlTask = React.useCallback( async (id: string, action: "pause" | "resume") => { try { const result = await api.controlTask(id, action); toast.success( action === "pause" ? `任务 #${id} 已暂停` : `任务 #${id} 已继续${result.queued ? ",已进入队列" : ""}`, ); } catch (e) { toast.error(`${action === "pause" ? "暂停" : "继续"}失败:${(e as Error).message}`); } finally { // 无论成败都刷新:失败多半是状态已变化,重新拉取才能让按钮回到正确形态。 lastRef.current = ""; load(); } }, [load], ); // deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件, // 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。 const deleteTasks = React.useCallback( async (ids: string[], options: DeleteTaskOptions, onProgress: (done: number) => void) => { const total: DeleteCounts = { assets_deleted: 0, assets_detached: 0, traffic_deleted: 0, files_deleted: false, findings_deleted: 0, llm_records_deleted: 0, }; const deleted: string[] = []; const failed: { id: string; message: string }[] = []; const warnings: string[] = []; for (const id of ids) { try { const r = await api.deleteTask(id, options); total.assets_deleted += r.assets_deleted; total.assets_detached += r.assets_detached; total.traffic_deleted += r.traffic_deleted; total.files_deleted = total.files_deleted || r.files_deleted; total.findings_deleted += r.findings_deleted; total.llm_records_deleted += r.llm_records_deleted; if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`); deleted.push(id); } catch (e) { failed.push({ id, message: (e as Error).message }); } onProgress(deleted.length + failed.length); } if (deleted.length > 0) { setSelectedIds((prev) => { const next = new Set(prev); for (const id of deleted) next.delete(id); return next; }); const details = deleteDetails(total); const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : ""); if (warnings.length > 0) { toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`); } else { toast.success(summary); } } if (failed.length > 0) { const head = failed .slice(0, 3) .map((f) => `#${f.id}(${f.message})`) .join(";"); toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`); } load(); }, [load], ); const selectedTasks = React.useMemo(() => tasks.filter((task) => selectedIds.has(task.id)), [tasks, selectedIds]); const pausableTaskIDs = React.useMemo( () => selectedTasks.filter((task) => taskControlAction(task.status) === "pause").map((task) => task.id), [selectedTasks], ); const resumableTaskIDs = React.useMemo( () => selectedTasks.filter((task) => taskControlAction(task.status) === "resume").map((task) => task.id), [selectedTasks], ); const controlSelectedTasks = React.useCallback( async (action: "pause" | "resume", ids: string[]) => { if (ids.length === 0 || batchControlling) return; if (ids.length > 100) { toast.error("一次最多控制 100 个任务"); return; } setBatchControlling(action); try { const result = await api.controlTasksBatch(ids, action); const succeeded = result.items.filter((item) => item.ok); const failed = result.items.filter((item) => !item.ok); if (succeeded.length > 0) { toast.success( action === "pause" ? `已暂停 ${succeeded.length} 个任务` : `已继续 ${succeeded.length} 个任务${succeeded.some((item) => item.queued) ? ",部分任务已进入队列" : ""}`, ); } if (failed.length > 0) { const details = failed .slice(0, 3) .map((item) => `#${item.id}(${item.error || "状态已变化"})`) .join(";"); toast.error(`${failed.length} 个任务操作失败:${details}${failed.length > 3 ? " 等" : ""}`); } lastRef.current = ""; load(); } catch (error) { toast.error(`${action === "pause" ? "批量暂停" : "批量继续"}失败:${(error as Error).message}`); } finally { setBatchControlling(null); } }, [batchControlling, load], ); >>>>>>> Stashed changes return (
setQuery(e.target.value)} className="pl-8" /> {query && ( )}
{filtered.length}/{tasks.length} 条
{tasks.length === 0 ? (
暂无任务,点击右上角「新建任务」开始。
) : filtered.length === 0 ? (
没有匹配的任务。
) : ( ID 名称 描述 目标 状态 目标进度 创建时间 运行时长 Token 操作 {paginated.map((task) => ( >>>>>> Stashed changes /> ))}
)}
); } // TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and // the POLL_MS list refresh only re-render the rows whose data actually moved — a table page // is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild // wholesale on every parent render. const TaskRow = React.memo(function TaskRow({ task, nowSec, onDelete, <<<<<<< Updated upstream }: { task: Task; nowSec: number; onDelete: (id: string) => void; ======= onControl, selected, onSelectedChange, }: { task: Task; nowSec: number; onDelete: (id: string, options: DeleteTaskOptions) => Promise; onControl: (id: string, action: "pause" | "resume") => Promise; selected: boolean; onSelectedChange: (id: string, checked: boolean) => void; >>>>>>> Stashed changes }) { return ( {task.id}
<<<<<<< Updated upstream {task.description} {task.active && ( )} ======= {task.name || 未命名} {task.active && } >>>>>>> Stashed changes
{task.description} {task.goal} {typeof task.goals_total === "number" && task.goals_total > 0 ? `${task.goals_met}/${task.goals_total}` : —} {fmtDateTime(task.created_unix)} {(() => { const secs = taskDuration(task, nowSec); if (secs <= 0) return —; return ( {fmtDuration(secs)} ); })()} {task.tokens ? ( 入 {fmtTokens(task.tokens.input_tokens)} {" · 缓 "} {fmtTokens(task.tokens.cache_read_tokens)} {" · 出 "} {fmtTokens(task.tokens.output_tokens)} ) : ( "—" )} <<<<<<< Updated upstream
确认删除任务 #{task.id}? {task.description ? `「${task.description.length > 80 ? task.description.slice(0, 80) + "…" : task.description}」` : "该任务"} 的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。 取消 onDelete(task.id)}> 删除 =======
>>>>>>> Stashed changes
); }); <<<<<<< Updated upstream ======= // 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。 function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) { if (pending) return ; if (action === "resume") return ; return ; } // TaskControlButton 是行内的暂停/继续开关。终态任务渲染为 disabled 而不是隐藏, // 这样各行操作列宽度一致,按钮位置不会随状态跳动。 function TaskControlButton({ task, onControl, }: { task: Task; onControl: (id: string, action: "pause" | "resume") => Promise; }) { const [pending, setPending] = React.useState(false); const action = taskControlAction(task.status); const label = action === "resume" ? "继续任务" : "暂停任务"; return ( ); } const emptyDeleteOptions = (): DeleteTaskOptions => ({ delete_assets: false, delete_traffic: false, delete_files: false, delete_findings: false, delete_llm_records: false, }); const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [ "delete_assets", "delete_traffic", "delete_files", "delete_findings", "delete_llm_records", ]; // DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the // single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when // several dialogs live in the same table. function DeleteOptionFields({ idPrefix, options, onOptionsChange, disabled, }: { idPrefix: string; options: DeleteTaskOptions; onOptionsChange: React.Dispatch>; disabled: boolean; }) { const selectedCount = deleteOptionKeys.filter((key) => options[key]).length; let allChecked: boolean | "indeterminate" = false; if (selectedCount === deleteOptionKeys.length) { allChecked = true; } else if (selectedCount > 0) { allChecked = "indeterminate"; } const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => { onOptionsChange((current) => ({ ...current, [key]: checked })); }; const updateAllOptions = (checked: boolean) => { onOptionsChange({ delete_assets: checked, delete_traffic: checked, delete_files: checked, delete_findings: checked, delete_llm_records: checked, }); }; return (
同时清理关联数据 updateAllOptions(checked === true)} /> 全部删除 选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。 updateOption("delete_assets", checked === true)} /> 关联资产 删除仅属于该任务的资产;共享资产只解除当前任务关联。 updateOption("delete_traffic", checked === true)} /> 关联流量 按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。 updateOption("delete_files", checked === true)} /> 任务文件 删除该任务工作目录中的上传文件、命令输出和其他产物。 updateOption("delete_findings", checked === true)} /> 关联漏洞 永久删除该任务产生的独立漏洞记录与漏洞报告。 updateOption("delete_llm_records", checked === true)} /> LLM 请求/响应记录 永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。
); } function DeleteTaskDialog({ task, onDelete, }: { task: Task; onDelete: (id: string, options: DeleteTaskOptions) => Promise; }) { const [open, setOpen] = React.useState(false); const [deleting, setDeleting] = React.useState(false); const [options, setOptions] = React.useState(emptyDeleteOptions); const handleOpenChange = (next: boolean) => { if (deleting) return; setOpen(next); if (next) setOptions(emptyDeleteOptions()); }; const handleDelete = async () => { setDeleting(true); try { await onDelete(task.id, options); setOpen(false); } finally { setDeleting(false); } }; return ( 确认删除任务 #{task.id}? {task.description ? ( <> 「 {task.description.length > 80 ? `${task.description.slice(0, 80)}…` : task.description} 」 ) : ( "该任务" )} 的执行记录与探索链路将被永久删除。 取消 { event.preventDefault(); void handleDelete(); }} > {deleting && } {deleting ? "删除中" : "删除"} ); } // BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options. // The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks) // and the button shows live progress. function BulkDeleteTasksDialog({ ids, onDelete, }: { ids: string[]; onDelete: (ids: string[], options: DeleteTaskOptions, onProgress: (done: number) => void) => Promise; }) { const [open, setOpen] = React.useState(false); const [deleting, setDeleting] = React.useState(false); const [done, setDone] = React.useState(0); const [options, setOptions] = React.useState(emptyDeleteOptions); const handleOpenChange = (next: boolean) => { if (deleting) return; setOpen(next); if (next) { setOptions(emptyDeleteOptions()); setDone(0); } }; const handleDelete = async () => { setDeleting(true); setDone(0); try { await onDelete(ids, options, setDone); setOpen(false); } finally { setDeleting(false); } }; return ( 确认删除所选 {ids.length} 个任务? 这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。
{ids.slice(0, 30).map((id) => ( #{id} ))} {ids.length > 30 && …等 {ids.length} 个}
取消 { event.preventDefault(); void handleDelete(); }} > {deleting && } {deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`}
); } >>>>>>> Stashed changes // CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with // 描述/目标 held by the page component every keystroke re-rendered the whole task table // behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as // input lag. Now typing only re-renders the drawer. function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { const [open, setOpen] = React.useState(false); const [name, setName] = React.useState(""); const [description, setDescription] = React.useState(""); const [goal, setGoal] = React.useState(""); const [profiles, setProfiles] = React.useState([]); const [llmProfile, setLlmProfile] = React.useState(ACTIVE_PROFILE); // sentinel = active const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响) // 方式1 文件上传:建任务前把文件暂存到 drafts//uploads/,拿回绝对路径追加进描述。 const [uploading, setUploading] = React.useState(false); const [uploadCount, setUploadCount] = React.useState(0); const draftIdRef = React.useRef(""); const fileInputRef = React.useRef(null); // load LLM profiles once for the create-task profile picker. React.useEffect(() => { api.llmProfiles().then(setProfiles).catch(() => setProfiles([])); }, []); // pickFiles uploads the chosen files into this draft's staging dir and appends their // absolute paths to the description; the task's agents open them by path via Read/Bash. async function pickFiles(files: FileList | null) { if (!files || files.length === 0) return; // crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。 if (!draftIdRef.current) { draftIdRef.current = globalThis.crypto?.randomUUID?.() ?? `d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; } setUploading(true); try { const r = await api.chatUpload("staging", draftIdRef.current, Array.from(files)); setDescription((prev) => appendUploads(prev, r.attachments)); setUploadCount((n) => n + r.attachments.length); } catch (e) { toast.error("上传失败:" + (e as Error).message); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-picking the same file } } async function createTask() { if (!description.trim() || !goal.trim()) { toast.error("请填写描述与目标"); return; } try { const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile); const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60; const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致 <<<<<<< Updated upstream await api.createTask(description.trim(), goal.trim(), pid, timeoutSec, seedFirstIntent, heartbeatSec); ======= await api.createTask({ name: name.trim(), description: description.trim(), goal: goal.trim(), llmProfileIds: llmProfileIDs.map(Number), sourceTaskIds: sourceTaskIDs, companyIds: companyIDs, timeoutSeconds: timeoutSec, seedFirstIntent, planHeartbeatSeconds: heartbeatSec, coverageEnabled, }); >>>>>>> Stashed changes toast.success("任务已创建"); setName(""); setDescription(""); setGoal(""); setLlmProfile(ACTIVE_PROFILE); setTimeoutMin(""); setHeartbeatMin("10"); setSeedFirstIntent(false); setCoverageEnabled(true); setUploadCount(0); draftIdRef.current = ""; setOpen(false); onCreated(); } catch (e) { toast.error("创建失败:" + (e as Error).message); } } return ( <<<<<<< Updated upstream {/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。 内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */} 新建任务 填写测试对象与目标,高级参数可按需展开。
{ setDescription(template.description); setGoal(template.goal); setUploadCount(0); }} portalContainer={sheetContentRef} />
setName(e.target.value)} />