diff --git a/src/lang/en/tasks.json b/src/lang/en/tasks.json index 3cf92f2ae9..f8789e6521 100644 --- a/src/lang/en/tasks.json +++ b/src/lang/en/tasks.json @@ -19,5 +19,36 @@ "7": "Failed", "8": "WaitingRetry", "9": "BeforeRetry" - } + }, + "retry_selected": "Retry Selected", + "cancel_selected": "Cancel Selected", + "delete_selected": "Delete Selected", + "filter": "Filter", + "expand": "Expand", + "fold": "Fold", + "expand_all": "Expand All", + "fold_all": "Fold All", + "attr": { + "name": "Name", + "creator": "Creator", + "state": "State", + "progress": "Progress", + "operation": "Operation", + "copy": { + "src": "Source Path", + "dst": "Destination Path" + }, + "upload": { + "path": "Path" + }, + "offline_download": { + "url": "URL", + "path": "Destination Path", + "transfer_src": "Source Path", + "transfer_dst": "Destination Path" + }, + "status": "Status", + "err": "Error" + }, + "show_only_mine": "Show only my tasks" } diff --git a/src/pages/manage/tasks/Aria2.tsx b/src/pages/manage/tasks/Aria2.tsx index 791af04704..5b1be4088d 100644 --- a/src/pages/manage/tasks/Aria2.tsx +++ b/src/pages/manage/tasks/Aria2.tsx @@ -1,13 +1,25 @@ import { VStack } from "@hope-ui/solid" import { useManageTitle } from "~/hooks" import { TypeTasks } from "./Tasks" +import { + getOfflineDownloadNameAnalyzer, + getOfflineDownloadTransferNameAnalyzer, +} from "./helper" +// deprecated const Aria2 = () => { useManageTitle("manage.sidemenu.aria2") return ( - - + + ) } diff --git a/src/pages/manage/tasks/Copy.tsx b/src/pages/manage/tasks/Copy.tsx index a868e97bf2..dd060ccfef 100644 --- a/src/pages/manage/tasks/Copy.tsx +++ b/src/pages/manage/tasks/Copy.tsx @@ -1,9 +1,26 @@ -import { useManageTitle } from "~/hooks" +import { useManageTitle, useT } from "~/hooks" import { TypeTasks } from "./Tasks" +import { getPath } from "./helper" const Copy = () => { + const t = useT() useManageTitle("manage.sidemenu.copy") - return + return ( + matches[3], + attrs: { + [t(`tasks.attr.copy.src`)]: (matches) => + getPath(matches[1], matches[2]), + [t(`tasks.attr.copy.dst`)]: (matches) => + getPath(matches[4], matches[5]), + }, + }} + /> + ) } export default Copy diff --git a/src/pages/manage/tasks/Qbit.tsx b/src/pages/manage/tasks/Qbit.tsx index 08c318e636..d763da784b 100644 --- a/src/pages/manage/tasks/Qbit.tsx +++ b/src/pages/manage/tasks/Qbit.tsx @@ -1,13 +1,25 @@ import { VStack } from "@hope-ui/solid" import { useManageTitle } from "~/hooks" import { TypeTasks } from "./Tasks" +import { + getOfflineDownloadNameAnalyzer, + getOfflineDownloadTransferNameAnalyzer, +} from "./helper" +// deprecated const Qbit = () => { useManageTitle("manage.sidemenu.qbit") return ( - - + + ) } diff --git a/src/pages/manage/tasks/Task.tsx b/src/pages/manage/tasks/Task.tsx index d97b840b56..c7210ba0eb 100644 --- a/src/pages/manage/tasks/Task.tsx +++ b/src/pages/manage/tasks/Task.tsx @@ -1,19 +1,25 @@ import { Badge, Button, + Center, + Checkbox, + Divider, + Flex, + Grid, + GridItem, Heading, + HStack, Progress, ProgressIndicator, - Stack, - Text, - useColorModeValue, + Spacer, VStack, } from "@hope-ui/solid" -import { createSignal, Show } from "solid-js" +import { createSignal, For, Show } from "solid-js" import { useT, useFetch } from "~/hooks" import { PEmptyResp, TaskInfo } from "~/types" import { handleResp, notify, r } from "~/utils" import { TasksProps } from "./Tasks" +import { me } from "~/store" enum TaskStateEnum { Pending, @@ -47,7 +53,18 @@ const StateMap: Record< const Creator = (props: { name: string; role: number }) => { if (props.role < 0) return null const roleColors = ["info", "neutral", "accent"] - return {props.name} + return ( + + {props.name} + + ) } export const TaskState = (props: { state: number }) => { @@ -59,7 +76,32 @@ export const TaskState = (props: { state: number }) => { ) } -export const Task = (props: TaskInfo & TasksProps) => { +export type TaskOrderBy = "name" | "creator" | "state" | "progress" + +export interface TaskCol { + name: TaskOrderBy | "operation" + textAlign: "left" | "right" | "center" + w: any +} + +export interface TaskControlCallback { + setSelected: (id: string, v: boolean) => void + setExpanded: (id: string, v: boolean) => void +} + +export const cols: TaskCol[] = [ + { + name: "name", + textAlign: "left", + w: me().role === 2 ? "calc(100% - 660px)" : "calc(100% - 540px)", + }, + { name: "creator", textAlign: "center", w: me().role === 2 ? "120px" : "0" }, + { name: "state", textAlign: "center", w: "100px" }, + { name: "progress", textAlign: "left", w: "160px" }, + { name: "operation", textAlign: "right", w: "280px" }, +] + +export const Task = (props: TaskInfo & TasksProps & TaskControlCallback) => { const t = useT() const operateName = props.done === "undone" ? "cancel" : "delete" const canRetry = props.done === "done" && props.state === TaskStateEnum.Failed @@ -71,58 +113,56 @@ export const Task = (props: TaskInfo & TasksProps) => { (): PEmptyResp => r.post(`/task/${props.type}/retry?tid=${props.id}`), ) const [deleted, setDeleted] = createSignal(false) + const matches: RegExpMatchArray | null = props.name.match( + props.nameAnalyzer.regex, + ) + const title = + matches === null ? props.name : props.nameAnalyzer.title(matches) return ( - - + + + { + e.stopPropagation() + }} + checked={props.selected} + onChange={(e: any) => { + props.setSelected(props.id, e.target.checked as boolean) + }} + /> - {props.name} + {title} - + + +
+ +
+
+
- - {props.status} - - - - {props.error} - - - - - {/* */} - - - - + + + {/* */} + + + - - + + + + + + + + + {(entry) => ( + <> + + {entry[0]} + + + {entry[1](matches as RegExpMatchArray)} + + + )} + + + + {t(`tasks.attr.status`)} + + {props.status} + + + {t(`tasks.attr.err`)} + + {props.error} + + + + + ) } diff --git a/src/pages/manage/tasks/Tasks.tsx b/src/pages/manage/tasks/Tasks.tsx index 28d0339605..4dae652658 100644 --- a/src/pages/manage/tasks/Tasks.tsx +++ b/src/pages/manage/tasks/Tasks.tsx @@ -1,32 +1,95 @@ -import { Button, Heading, HStack, VStack } from "@hope-ui/solid" -import { createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { + Button, + Checkbox, + Flex, + Heading, + HStack, + Input, + Spacer, + Text, + VStack, +} from "@hope-ui/solid" +import { + batch, + createEffect, + createMemo, + createSignal, + For, + JSX, + onCleanup, + Setter, + Show, +} from "solid-js" import { Paginator } from "~/components" import { useFetch, useT } from "~/hooks" import { PEmptyResp, PResp, TaskInfo } from "~/types" -import { handleResp, r } from "~/utils" -import { Task } from "./Task" +import { handleResp, notify, r } from "~/utils" +import { TaskCol, cols, Task, TaskOrderBy } from "./Task" +import { me } from "~/store" + +export interface TaskNameAnalyzer { + regex: RegExp + title: (matches: RegExpMatchArray) => string + attrs: { [attr: string]: (matches: RegExpMatchArray) => JSX.Element } +} export interface TasksProps { type: string done: string + nameAnalyzer: TaskNameAnalyzer canRetry?: boolean } + +export interface TaskViewAttribute { + selected: boolean + expanded: boolean +} + 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 [tasks, setTasks] = createSignal<(TaskInfo & TaskViewAttribute)[]>([]) + const [orderBy, setOrderBy] = createSignal("name") + const [orderReverse, setOrderReverse] = createSignal(false) + const sorter: Record number> = { + name: (a, b) => (a.name > b.name ? 1 : -1), + creator: (a, b) => + a.creator === b.creator + ? a.id > b.id + ? 1 + : -1 + : a.creator > b.creator + ? 1 + : -1, + state: (a, b) => + a.state === b.state ? (a.id > b.id ? 1 : -1) : a.state > b.state ? 1 : -1, + progress: (a, b) => (a.progress < b.progress ? 1 : -1), + } + const curSorter = createMemo(() => { + return (a: TaskInfo, b: TaskInfo) => + (orderReverse() ? -1 : 1) * sorter[orderBy()](a, b) + }) const refresh = async () => { const resp = await get() + const selectMap: Record = {} + const expandMap: Record = {} + for (const task of tasks()) { + selectMap[task.id] = task.selected ?? false + expandMap[task.id] = task.expanded ?? false + } handleResp(resp, (data) => setTasks( - data?.sort((a, b) => { - if (a.id > b.id) { - return 1 - } - return -1 - }) ?? [], + data + ?.map((task) => { + return { + ...task, + selected: selectMap[task.id] ?? false, + expanded: expandMap[task.id] ?? false, + } + }) + .sort(curSorter()) ?? [], ), ) } @@ -44,18 +107,162 @@ export const Tasks = (props: TasksProps) => { const [retryFailedLoading, retryFailed] = useFetch( (): PEmptyResp => r.post(`/task/${props.type}/retry_failed`), ) + const [regexFilterValue, setRegexFilterValue] = createSignal("") + const [regexFilter, setRegexFilter] = createSignal(new RegExp("")) + const [regexFilterCompileFailed, setRegexFilterCompileFailed] = + createSignal(false) + createEffect(() => { + try { + setRegexFilter(new RegExp(regexFilterValue())) + setRegexFilterCompileFailed(false) + } catch (_) { + setRegexFilterCompileFailed(true) + } + }) + const [showOnlyMine, setShowOnlyMine] = createSignal(me().role !== 2) + const taskFilter = createMemo(() => { + const regex = regexFilter() + const mine = showOnlyMine() + return (task: TaskInfo): boolean => + regex.test(task.name) && (!mine || task.creator === me().username) + }) + const filteredTask = createMemo(() => { + return tasks().filter(taskFilter()) + }) + const allChecked = createMemo(() => { + return filteredTask() + .map((task) => task.selected) + .every(Boolean) + }) + const isIndeterminate = createMemo(() => { + return ( + filteredTask() + .map((task) => task.selected) + .some(Boolean) && !allChecked() + ) + }) + const setSelected = (id: string, v: boolean) => { + setTasks( + tasks().map((task) => { + if (task.id === id) task.selected = v + return task + }), + ) + } + const selectAll = (v: boolean) => { + const filter = taskFilter() + setTasks( + tasks().map((task) => { + if (filter(task)) task.selected = v + return task + }), + ) + } + const allExpanded = createMemo(() => { + return filteredTask() + .map((task) => task.expanded) + .every(Boolean) + }) + const setExpanded = (id: string, v: boolean) => { + setTasks( + tasks().map((task) => { + if (task.id === id) task.expanded = v + return task + }), + ) + } + const expandedAll = (v: boolean) => { + const filter = taskFilter() + setTasks( + tasks().map((task) => { + if (filter(task)) task.expanded = v + return task + }), + ) + } + const doWithSelected = ( + loadingSetter: Setter, + fetchFunc: (task: TaskInfo) => PEmptyResp, + successCallback?: () => void, + ) => { + return async () => { + loadingSetter(true) + const promises = filteredTask() + .filter((task) => task.selected) + .map(fetchFunc) + let success = true + for (const p of promises) { + const resp = await p + if (resp.code !== 200) { + success = false + handleResp(resp) + if (resp.code === 401) return + } + } + loadingSetter(false) + if (success) successCallback?.() + } + } + const [retrySelectedLoading, setRetrySelectedLoading] = createSignal(false) + const retrySelected = doWithSelected( + setRetrySelectedLoading, + (task) => { + return r.post(`/task/${props.type}/retry?tid=${task.id}`) + }, + () => { + notify.info(t("tasks.retry")) + refresh() + }, + ) + const [operateSelectedLoading, setOperateSelectedLoading] = + createSignal(false) + const operateSelected = doWithSelected( + setOperateSelectedLoading, + (task) => { + return r.post(`/task/${props.type}/${operateName}?tid=${task.id}`) + }, + () => { + notify.success(t("global.delete_success")) + refresh() + }, + ) const [page, setPage] = createSignal(1) const pageSize = 20 + const operateName = props.done === "undone" ? "cancel" : "delete" const curTasks = createMemo(() => { const start = (page() - 1) * pageSize const end = start + pageSize - return tasks().slice(start, end) + return filteredTask().slice(start, end) }) + const itemProps = (col: TaskCol) => { + return { + fontWeight: "bold", + fontSize: "$sm", + color: "$neutral11", + textAlign: col.textAlign as any, + } + } + const itemPropsSort = (col: TaskCol) => { + return { + cursor: "pointer", + onClick: () => { + if (orderBy() === col.name) { + setOrderReverse(!orderReverse()) + } else { + batch(() => { + setOrderBy(col.name as TaskOrderBy) + setOrderReverse(false) + }) + } + refresh() + }, + } + } return ( {t(`tasks.${props.done}`)} - - + + @@ -88,13 +295,110 @@ export const Tasks = (props: TasksProps) => { > {t(`tasks.clear_succeeded`)} + + + + + + setRegexFilterValue(e.target.value as string)} + invalid={regexFilterCompileFailed()} + /> + + setShowOnlyMine(e.target.checked as boolean)} + > + {t(`tasks.show_only_mine`)} + + + + + + + selectAll(e.target.checked as boolean)} + /> + + {t(`tasks.attr.${cols[0].name}`)} + + + + + {t(`tasks.attr.${cols[1].name}`)} + + + + {t(`tasks.attr.${cols[2].name}`)} + + + {t(`tasks.attr.${cols[3].name}`)} + + + + + {t(`tasks.attr.${cols[4].name}`)} + + + - - - {(task) => } + + {(_, i) => ( + + )} + { setPage(p) @@ -104,7 +408,11 @@ export const Tasks = (props: TasksProps) => { ) } -export const TypeTasks = (props: { type: string; canRetry?: boolean }) => { +export const TypeTasks = (props: { + type: string + nameAnalyzer: TaskNameAnalyzer + canRetry?: boolean +}) => { const t = useT() return ( @@ -112,7 +420,12 @@ export const TypeTasks = (props: { type: string; canRetry?: boolean }) => { {(done) => ( - + )} diff --git a/src/pages/manage/tasks/Upload.tsx b/src/pages/manage/tasks/Upload.tsx index 1f77f6dc63..319e3f64f7 100644 --- a/src/pages/manage/tasks/Upload.tsx +++ b/src/pages/manage/tasks/Upload.tsx @@ -1,9 +1,23 @@ -import { useManageTitle } from "~/hooks" +import { useManageTitle, useT } from "~/hooks" import { TypeTasks } from "./Tasks" +import { getPath } from "./helper" const Upload = () => { + const t = useT() useManageTitle("manage.sidemenu.upload") - return + return ( + matches[1], + attrs: { + [t(`tasks.attr.upload.path`)]: (matches) => + getPath(matches[2], matches[3]), + }, + }} + /> + ) } export default Upload diff --git a/src/pages/manage/tasks/helper.tsx b/src/pages/manage/tasks/helper.tsx new file mode 100644 index 0000000000..066fb26f1a --- /dev/null +++ b/src/pages/manage/tasks/helper.tsx @@ -0,0 +1,62 @@ +import { createSignal, JSX } from "solid-js" +import { me } from "~/store" +import { TaskNameAnalyzer } from "./Tasks" +import { useT } from "~/hooks" + +export const getPath = (device: string, path: string): JSX.Element => { + const fullPath = (device === "/" ? "" : device) + path + const prefix = me().base_path === "/" ? "" : me().base_path + const accessible = fullPath.startsWith(prefix) + const [underline, setUnderline] = createSignal(false) + return accessible ? ( + setUnderline(true)} + onMouseOut={() => setUnderline(false)} + href={fullPath.slice(prefix.length)} + > + {fullPath} + + ) : ( +

{fullPath}

+ ) +} + +export const getOfflineDownloadNameAnalyzer = (): TaskNameAnalyzer => { + const t = useT() + const [underline, setUnderline] = createSignal(false) + return { + regex: /^download (.+) to \((.+)\)$/, + title: (matches) => matches[1], + attrs: { + [t(`tasks.attr.offline_download.url`)]: (matches) => ( + setUnderline(true)} + onMouseOut={() => setUnderline(false)} + href={matches[1]} + target="_blank" + > + {matches[1]} + + ), + [t(`tasks.attr.offline_download.path`)]: (matches) => + getPath("", matches[2]), + }, + } +} + +export const getOfflineDownloadTransferNameAnalyzer = (): TaskNameAnalyzer => { + const t = useT() + return { + regex: /^transfer ((?:.*\/)?(.+)) to \[(.+)]$/, + title: (matches) => matches[2], + attrs: { + [t(`tasks.attr.offline_download.transfer_src`)]: (matches) => ( +

{matches[1]}

+ ), + [t(`tasks.attr.offline_download.transfer_dst`)]: (matches) => + getPath("", matches[3]), + }, + } +} diff --git a/src/pages/manage/tasks/offline_download.tsx b/src/pages/manage/tasks/offline_download.tsx index 2fdc00d9c2..f0d8120da1 100644 --- a/src/pages/manage/tasks/offline_download.tsx +++ b/src/pages/manage/tasks/offline_download.tsx @@ -1,13 +1,25 @@ import { VStack } from "@hope-ui/solid" import { useManageTitle } from "~/hooks" import { TypeTasks } from "./Tasks" +import { + getOfflineDownloadNameAnalyzer, + getOfflineDownloadTransferNameAnalyzer, +} from "./helper" const OfflineDownload = () => { useManageTitle("manage.sidemenu.offline_download") return ( - - + + ) } diff --git a/src/pages/manage/users/AddOrEdit.tsx b/src/pages/manage/users/AddOrEdit.tsx index ccdaa53189..8af47bf60a 100644 --- a/src/pages/manage/users/AddOrEdit.tsx +++ b/src/pages/manage/users/AddOrEdit.tsx @@ -14,6 +14,7 @@ import { handleResp, notify, r } from "~/utils" import { PEmptyResp, PResp, User, UserMethods, UserPermissions } from "~/types" import { createStore } from "solid-js/store" import { For, Show } from "solid-js" +import { me, setMe } from "~/store" const Permission = (props: { can: boolean @@ -148,8 +149,10 @@ const AddOrEdit = () => { onClick={async () => { const resp = await ok() // TODO maybe can use handleRespWithNotifySuccess - handleResp(resp, () => { + handleResp(resp, async () => { notify.success(t("global.save_success")) + if (user.username === me().username) + handleResp(await r.get("/me"), setMe) back() }) }}