diff --git a/src/lang/en/tasks.json b/src/lang/en/tasks.json index a96897f90..5d4578731 100644 --- a/src/lang/en/tasks.json +++ b/src/lang/en/tasks.json @@ -26,6 +26,13 @@ "retry_selected": "Retry Selected", "cancel_selected": "Cancel Selected", "delete_selected": "Delete Selected", + "delete_by_path": "Delete by Path", + "cancel_by_path": "Cancel by Path", + "retry_by_path": "Retry by Path", + "path_tips": "Matches tasks whose source or destination path is under this prefix. Does not require selecting tasks from the list.", + "delete_by_path_confirm": "Delete all matching tasks under {{path}}? Running tasks will be canceled first. This cannot be undone.", + "path_explicit_root_required": "Enter / explicitly to operate on all tasks. Inputs such as . or // are not accepted as the root path.", + "path_batch_result": "Matched {{matched}} task(s); processed {{processed}}.", "filter": "Filter", "expand": "Expand", "fold": "Fold", diff --git a/src/pages/manage/tasks/Tasks.tsx b/src/pages/manage/tasks/Tasks.tsx index 4bd09de88..d3cd3fd9c 100644 --- a/src/pages/manage/tasks/Tasks.tsx +++ b/src/pages/manage/tasks/Tasks.tsx @@ -19,13 +19,31 @@ import { onCleanup, Show, } from "solid-js" -import { Paginator } from "~/components" +import { ModalInput, Paginator } from "~/components" import { useFetch, useT } from "~/hooks" -import { PEmptyResp, PResp, TaskInfo } from "~/types" +import { PEmptyResp, PResp, Resp, TaskInfo, TaskPathResult } from "~/types" import { handleResp, notify, r } from "~/utils" import { TaskCol, cols, Task, TaskOrderBy, TaskLocal } from "./Task" import { me } from "~/store" +type PathBatchAction = "delete" | "cancel" | "retry" + +const normalizesToRoot = (path: string) => { + const normalized = path + .replaceAll("\\", "/") + .split("/") + .reduce((parts, part) => { + if (!part || part === ".") return parts + if (part === "..") { + parts.pop() + return parts + } + parts.push(part) + return parts + }, []) + return normalized.length === 0 +} + export interface TaskNameAnalyzer { regex: RegExp title: (matches: RegExpMatchArray) => string @@ -212,6 +230,49 @@ export const Tasks = (props: TasksProps) => { (): PEmptyResp => r.post(`/task/${props.type}/${operateName}_some`, getSelectedId()), ) + const [pathModalOpen, setPathModalOpen] = createSignal(false) + const [pathAction, setPathAction] = createSignal("delete") + const [pathBatchLoading, setPathBatchLoading] = createSignal(false) + const openPathModal = (action: PathBatchAction) => { + setPathAction(action) + setPathModalOpen(true) + } + const submitPathBatch = async (path: string) => { + const trimmed = path.trim() + if (!trimmed) { + notify.warning(t("global.empty_input")) + return + } + if (me().role === 2 && trimmed !== "/" && normalizesToRoot(trimmed)) { + notify.warning(t("tasks.path_explicit_root_required")) + return + } + if (pathAction() === "delete") { + const ok = window.confirm( + t("tasks.delete_by_path_confirm", { path: trimmed }), + ) + if (!ok) return + } + setPathBatchLoading(true) + try { + const resp = (await r.post( + `/task/${props.type}/${pathAction()}_by_path`, + { path: trimmed }, + )) as Resp + handleResp(resp, (data) => { + notify.success( + t("tasks.path_batch_result", { + matched: data?.matched ?? 0, + processed: data?.processed ?? 0, + }), + ) + setPathModalOpen(false) + refresh() + }) + } finally { + setPathBatchLoading(false) + } + } const notifyIndividualError = (msg: Record) => { Object.entries(msg).forEach(([key, value]) => { notify.error(`${key}: ${value}`) @@ -326,6 +387,31 @@ export const Tasks = (props: TasksProps) => { > {t(`tasks.${operateName}_selected`)} + + + + + + + { + setPathModalOpen(false)} + loading={pathBatchLoading()} + onSubmit={submitPathBatch} + />