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()
})
}}