From 60313ca5bdb83d208013145596fa60039521753d Mon Sep 17 00:00:00 2001 From: fryeggs <42050399+fryeggs@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:40:29 +0800 Subject: [PATCH] feat(tasks): add copy queue controls and task visibility Co-Authored-By: OpenAI Codex --- src/app/i18n.ts | 8 +- src/lang/en/tasks.json | 12 +++ src/pages/manage/tasks/Copy.tsx | 165 +++++++++++++++++++++++++++---- src/pages/manage/tasks/Task.tsx | 5 +- src/pages/manage/tasks/Tasks.tsx | 99 +++++++++++++++---- 5 files changed, 244 insertions(+), 45 deletions(-) diff --git a/src/app/i18n.ts b/src/app/i18n.ts index 48b0ebbe8..be465ce23 100644 --- a/src/app/i18n.ts +++ b/src/app/i18n.ts @@ -39,7 +39,7 @@ export type Lang = keyof typeof langs export type RawDictionary = typeof en.dict export type Dictionary = i18n.Flatten -// English dictionary cache for fallback +// English dictionary cache for fallback. let enDictCache: Dictionary | null = null const fetchEnDict = async (): Promise => { @@ -50,22 +50,18 @@ const fetchEnDict = async (): Promise => { return enDictCache } -// Fetch and flatten the dictionary, with English fallback +// Fetch and flatten the dictionary, with English fallback for new keys. const fetchDictionary = async (locale: Lang): Promise => { try { const dict: RawDictionary = (await import(`~/lang/${locale}/entry.ts`)).dict const flatDict = i18n.flatten(dict) - - // If not English, merge with English as fallback (English keys underneath, locale on top) if (locale !== "en") { const enDict = await fetchEnDict() return { ...enDict, ...flatDict } as Dictionary } - return flatDict } catch (err) { console.error(`Error loading dictionary for locale: ${locale}`, err) - // Fallback to English if the requested locale fails to load if (locale !== "en") { return await fetchEnDict() } diff --git a/src/lang/en/tasks.json b/src/lang/en/tasks.json index a96897f90..d87198f1c 100644 --- a/src/lang/en/tasks.json +++ b/src/lang/en/tasks.json @@ -8,6 +8,18 @@ "decompress_upload": "Upload extracted files into target storage", "done": "Completed", "undone": "Running", + "running_count": "Running: {{count}}", + "waiting_count": "Waiting: {{count}}", + "show_running_only": "Show running only", + "show_all_undone": "Show all unfinished", + "copy_queue_status": "Copy workers: {{count}}", + "copy_queue_paused": "Copy queue paused", + "pause_copy_queue": "Pause queued copies", + "resume_copy_queue": "Resume with {{count}} workers", + "pause_copy_queue_help": "Running files will finish; only queued copy tasks are paused.", + "copy_queue_paused_success": "Queued copy tasks paused", + "copy_queue_resumed_success": "Copy queue resumed with {{count}} workers", + "invalid_copy_worker_setting": "Invalid copy worker setting", "clear_succeeded": "Clear Succeeded", "retry": "Retry", "retry_failed": "Retry Failed", diff --git a/src/pages/manage/tasks/Copy.tsx b/src/pages/manage/tasks/Copy.tsx index f5973d5d1..c6ffe7eb4 100644 --- a/src/pages/manage/tasks/Copy.tsx +++ b/src/pages/manage/tasks/Copy.tsx @@ -1,29 +1,156 @@ -import { useManageTitle, useT } from "~/hooks" +import { Button, HStack, Text, VStack } from "@hope-ui/solid" +import { createMemo, createSignal, Show } from "solid-js" +import { useFetch, useManageTitle, useT } from "~/hooks" +import { me } from "~/store" +import { PEmptyResp, PResp, SettingItem, UserRole } from "~/types" +import { handleResp, notify, r } from "~/utils" import { TypeTasks } from "./Tasks" import { getPath } from "./helper" +const copyWorkersSettingKey = "copy_task_threads_num" +const resumeWorkersStorageKey = "openlist-copy-resume-workers" +const defaultResumeWorkers = 3 + +const validPositiveWorkers = (value: number) => + Number.isInteger(value) && value >= 1 && value <= 32 + +const getRememberedWorkers = () => { + try { + const value = Number(localStorage.getItem(resumeWorkersStorageKey)) + return validPositiveWorkers(value) ? value : defaultResumeWorkers + } catch (_) { + return defaultResumeWorkers + } +} + +const rememberWorkers = (value: number) => { + if (!validPositiveWorkers(value)) return + try { + localStorage.setItem(resumeWorkersStorageKey, value.toString()) + } catch (_) { + // The control remains usable when browser storage is unavailable. + } +} + +const CopyQueueControl = () => { + const t = useT() + const [setting, setSetting] = createSignal() + const [workers, setWorkers] = createSignal() + const [resumeWorkers, setResumeWorkers] = createSignal(getRememberedWorkers()) + const [loadSettingLoading, loadSetting] = useFetch((): PResp => + r.get(`/admin/setting/get?key=${copyWorkersSettingKey}`), + ) + const [saveSettingLoading, saveSetting] = useFetch( + (item: SettingItem): PEmptyResp => r.post("/admin/setting/save", [item]), + ) + + const updateFromSetting = (item: SettingItem) => { + const value = Number(item.value) + if (!Number.isInteger(value) || value < 0 || value > 32) { + notify.error(t("tasks.invalid_copy_worker_setting")) + setSetting(undefined) + setWorkers(undefined) + return + } + setSetting(item) + setWorkers(value) + if (validPositiveWorkers(value)) { + setResumeWorkers(value) + rememberWorkers(value) + } + } + + const refresh = async () => { + const resp = await loadSetting() + handleResp(resp, updateFromSetting) + } + + const paused = createMemo(() => workers() === 0) + const toggleQueue = async () => { + const item = setting() + const currentWorkers = workers() + if (item === undefined || currentWorkers === undefined) return + const nextWorkers = currentWorkers === 0 ? resumeWorkers() : 0 + if (nextWorkers !== 0 && !validPositiveWorkers(nextWorkers)) { + notify.error(t("tasks.invalid_copy_worker_setting")) + return + } + const updated = { ...item, value: nextWorkers.toString() } + const resp = await saveSetting(updated) + handleResp(resp, () => { + updateFromSetting(updated) + notify.success( + nextWorkers === 0 + ? t("tasks.copy_queue_paused_success") + : t("tasks.copy_queue_resumed_success", { count: nextWorkers }), + ) + }) + } + + refresh() + + return ( + + + + {paused() + ? t("tasks.copy_queue_paused") + : t("tasks.copy_queue_status", { count: workers() ?? "-" })} + + + + + + {t("tasks.pause_copy_queue_help")} + + + ) +} + const Copy = () => { const t = useT() useManageTitle("manage.sidemenu.copy") return ( - { - if (matches[4] !== "") return matches[4] - return matches[2] === "" ? "/" : matches[2] - }, - attrs: { - [t(`tasks.attr.copy.src`)]: (matches) => - getPath(matches[1], matches[3]), - [t(`tasks.attr.copy.dst`)]: (matches) => - getPath(matches[5], matches[6]), - }, - }} - /> + + + + + { + if (matches[4] !== "") return matches[4] + return matches[2] === "" ? "/" : matches[2] + }, + attrs: { + [t(`tasks.attr.copy.src`)]: (matches) => + getPath(matches[1], matches[3]), + [t(`tasks.attr.copy.dst`)]: (matches) => + getPath(matches[5], matches[6]), + }, + }} + /> + ) } diff --git a/src/pages/manage/tasks/Task.tsx b/src/pages/manage/tasks/Task.tsx index abab7f4ae..abf9b0171 100644 --- a/src/pages/manage/tasks/Task.tsx +++ b/src/pages/manage/tasks/Task.tsx @@ -77,7 +77,7 @@ export const TaskState = (props: { state: number }) => { ) } -export type TaskOrderBy = "name" | "creator" | "state" | "progress" +export type TaskOrderBy = "name" | "creator" | "state" | "progress" | "end_time" export interface TaskCol { name: TaskOrderBy | "speed" | "operation" @@ -151,7 +151,7 @@ export const Task = (props: TaskAttribute & TasksProps & TaskLocalSetter) => { } return `${delta.toFixed(2)} ${units[unit_i]}` } - if (props.done) { + if (props.done === "done") { if ( props.start_time !== props.end_time && props.progress > 0 && @@ -162,6 +162,7 @@ export const Task = (props: TaskAttribute & TasksProps & TaskLocalSetter) => { speedText = parseSpeedText(timeDelta, lengthDelta) } } else if ( + props.done === "undone" && props.prevProgress !== undefined && props.prevFetchTime !== undefined ) { diff --git a/src/pages/manage/tasks/Tasks.tsx b/src/pages/manage/tasks/Tasks.tsx index c41d79111..4d24a53ce 100644 --- a/src/pages/manage/tasks/Tasks.tsx +++ b/src/pages/manage/tasks/Tasks.tsx @@ -55,24 +55,44 @@ export interface TaskLocalSetter { export type TaskAttribute = TaskInfo & TaskViewAttribute & TaskLocalContainer +const TaskState = { + Pending: 0, + Running: 1, + Canceling: 3, + Errored: 5, + Failing: 6, + WaitingRetry: 8, + BeforeRetry: 9, +} as const + const undoneStatePriority: Record = { - 1: 0, - 0: 1, - 8: 1, - 9: 1, - 3: 2, - 5: 2, - 6: 2, + [TaskState.Running]: 0, + [TaskState.Pending]: 1, + [TaskState.WaitingRetry]: 1, + [TaskState.BeforeRetry]: 1, + [TaskState.Canceling]: 2, + [TaskState.Errored]: 2, + [TaskState.Failing]: 2, } +const waitingStates = new Set([ + TaskState.Pending, + TaskState.WaitingRetry, + TaskState.BeforeRetry, +]) export const Tasks = (props: TasksProps) => { const t = useT() const [loading, get] = useFetch((): PResp => r.get(`/task/${props.type}/${props.done}`), ) const [tasks, setTasks] = createSignal([]) - const [orderBy, setOrderBy] = createSignal("name") - const [orderReverse, setOrderReverse] = createSignal(false) + const [orderBy, setOrderBy] = createSignal( + props.done === "done" ? "end_time" : "name", + ) + const [orderReverse, setOrderReverse] = createSignal(props.done === "done") + const [showOnlyRunning, setShowOnlyRunning] = createSignal(false) + const [page, setPage] = createSignal(1) + const pageSize = 20 const sorter: Record number> = { name: (a, b) => (a.name > b.name ? 1 : -1), creator: (a, b) => @@ -93,6 +113,11 @@ export const Tasks = (props: TasksProps) => { : a.progress < b.progress ? 1 : -1, + end_time: (a, b) => { + const aTime = a.end_time ? new Date(a.end_time).getTime() : 0 + const bTime = b.end_time ? new Date(b.end_time).getTime() : 0 + return aTime === bTime ? (a.id > b.id ? 1 : -1) : aTime > bTime ? 1 : -1 + }, } const curSorter = createMemo(() => { return (a: TaskInfo, b: TaskInfo) => { @@ -152,10 +177,8 @@ export const Tasks = (props: TasksProps) => { }) } refresh() - if (props.done === "undone") { - const interval = setInterval(refresh, 2000) - onCleanup(() => clearInterval(interval)) - } + const interval = setInterval(refresh, props.done === "undone" ? 2000 : 10000) + onCleanup(() => clearInterval(interval)) const [clearDoneLoading, clearDone] = useFetch((): PEmptyResp => r.post(`/task/${props.type}/clear_done`), ) @@ -177,11 +200,20 @@ export const Tasks = (props: TasksProps) => { } }) const [showOnlyMine, setShowOnlyMine] = createSignal(me().role !== 2) + const runningCount = createMemo( + () => tasks().filter((task) => task.state === TaskState.Running).length, + ) + const waitingCount = createMemo( + () => tasks().filter((task) => waitingStates.has(task.state)).length, + ) const taskFilter = createMemo(() => { const regex = regexFilter() const mine = showOnlyMine() + const runningOnly = props.done === "undone" && showOnlyRunning() return (task: TaskInfo): boolean => - regex.test(task.name) && (!mine || task.creator === me().username) + regex.test(task.name) && + (!mine || task.creator === me().username) && + (!runningOnly || task.state === TaskState.Running) }) const filteredTask = createMemo(() => { return tasks().filter(taskFilter()) @@ -235,9 +267,13 @@ export const Tasks = (props: TasksProps) => { notify.error(`${key}: ${value}`) }) } - const [page, setPage] = createSignal(1) - const pageSize = 20 const operateName = props.done === "undone" ? "cancel" : "delete" + createEffect(() => { + const maxPage = Math.max(1, Math.ceil(filteredTask().length / pageSize)) + if (page() > maxPage) { + setPage(maxPage) + } + }) const curTasks = createMemo(() => { const start = (page() - 1) * pageSize const end = start + pageSize @@ -263,6 +299,7 @@ export const Tasks = (props: TasksProps) => { setOrderReverse(false) }) } + setPage(1) refresh() }, } @@ -282,6 +319,26 @@ export const Tasks = (props: TasksProps) => { {t(`tasks.${props.done}`)} + + + {t("tasks.running_count", { count: runningCount() })} + + + {t("tasks.waiting_count", { count: waitingCount() })} + + +