diff --git a/src/components/ModalInput.tsx b/src/components/ModalInput.tsx index fd6ede9b4..7d89e6aad 100644 --- a/src/components/ModalInput.tsx +++ b/src/components/ModalInput.tsx @@ -25,6 +25,7 @@ export type ModalInputProps = { title: string isRenamingFile?: boolean onSubmit?: (text: string) => void + onSubmitWithValue?: (text: string, setValue: (value: string) => void) => void type?: string defaultValue?: string loading?: boolean @@ -91,7 +92,11 @@ export const ModalInput = (props: ModalInputProps) => { return } } - props.onSubmit?.(value()) + if (props.onSubmitWithValue) { + props.onSubmitWithValue(value(), setValue) + } else { + props.onSubmit?.(value()) + } } const handleInput = (newValue: string) => { diff --git a/src/lang/en/tasks.json b/src/lang/en/tasks.json index a96897f90..e48e703f4 100644 --- a/src/lang/en/tasks.json +++ b/src/lang/en/tasks.json @@ -50,7 +50,9 @@ "path": "Destination Path", "transfer_src": "Source Path", "transfer_src_local": "Source Path (Local)", - "transfer_dst": "Destination Path" + "transfer_dst": "Destination Path", + "list_title": "Offline Download Tasks", + "no_tasks": "No ongoing tasks" }, "decompress": { "src": "Source Path", diff --git a/src/pages/home/toolbar/OfflineDownload.tsx b/src/pages/home/toolbar/OfflineDownload.tsx index 7eaa6793e..55f1a1287 100644 --- a/src/pages/home/toolbar/OfflineDownload.tsx +++ b/src/pages/home/toolbar/OfflineDownload.tsx @@ -1,17 +1,33 @@ -import { Box, createDisclosure } from "@hope-ui/solid" -import { ModalInput, SelectWrapper } from "~/components" +import { + Box, + createDisclosure, + Heading, + HStack, + Text, + VStack, +} from "@hope-ui/solid" +import bencode from "bencode" +import crypto from "crypto-js" +import { + createEffect, + createSignal, + For, + onCleanup, + onMount, + Show, +} from "solid-js" +import { FullLoading, ModalInput, SelectWrapper } from "~/components" import { useFetch, useRouter, useT } from "~/hooks" +import { useTasks } from "~/store/task" +import { PResp } from "~/types" import { - offlineDownload, bus, + handleResp, handleRespWithNotifySuccess, + offlineDownload, r, - handleResp, } from "~/utils" -import { createSignal, onCleanup, onMount } from "solid-js" -import { PResp } from "~/types" -import bencode from "bencode" -import crypto from "crypto-js" +import OfflineDownloadTaskItem from "./TaskProgress" const deletePolicies = [ "upload_download_stream", @@ -72,9 +88,13 @@ export const OfflineDownload = () => { const { isOpen, onOpen, onClose } = createDisclosure() const [loading, ok] = useFetch(offlineDownload) const { pathname } = useRouter() + + // 监听工具栏事件 const handler = (name: string) => { if (name === "offline_download") { onOpen() + setShowTasks(true) + fetchOfflineDownloadTasks(true) } } bus.on("tool", handler) @@ -82,7 +102,53 @@ export const OfflineDownload = () => { bus.off("tool", handler) }) - // convert torrent file to magnet link + const { tasks, loading: tasksLoading, fetchOfflineDownloadTasks } = useTasks() + const [showTasks, setShowTasks] = createSignal(false) + + const handleSubmit = async ( + urls: string, + setValue: (value: string) => void, + ) => { + const resp = await ok(pathname(), urls.split("\n"), tool(), deletePolicy()) + handleRespWithNotifySuccess(resp, () => { + fetchOfflineDownloadTasks(true) // 显示 loading + setValue("") + }) + } + + // 定时刷新任务进度(仅当显示任务列表时) + let timer: number | undefined + let isFetching = false + createEffect(() => { + if (showTasks()) { + const poll = async () => { + if (!showTasks()) return + if (isFetching) { + timer = window.setTimeout(poll, 3000) + return + } + isFetching = true + try { + await fetchOfflineDownloadTasks(false) + } finally { + isFetching = false + } + if (showTasks()) { + timer = window.setTimeout(poll, 3000) + } + } + void poll() + } else { + clearTimeout(timer) + timer = undefined + } + }) + + onCleanup(() => { + clearTimeout(timer) + }) + + // 拖拽种子文件转换为磁力链接 const handleTorrentFileDrop = async ( e: DragEvent, setValue: (value: string) => void, @@ -111,7 +177,10 @@ export const OfflineDownload = () => { title="home.toolbar.offline_download" type="text" opened={isOpen()} - onClose={onClose} + onClose={() => { + onClose() + setShowTasks(false) + }} loading={toolsLoading() || loading()} tips={t("home.toolbar.offline_download-tips")} onDrop={handleTorrentFileDrop} @@ -135,36 +204,53 @@ export const OfflineDownload = () => { } bottomSlot={ - - setDeletePolicy(v as DeletePolicy)} - options={deletePolicies - .filter((policy) => - policy == "upload_download_stream" - ? tool() === "SimpleHttp" - : true, - ) - .map((policy) => { - return { + + + setDeletePolicy(v as DeletePolicy)} + options={deletePolicies + .filter((policy) => + policy === "upload_download_stream" + ? tool() === "SimpleHttp" + : true, + ) + .map((policy) => ({ value: policy, label: t(`home.toolbar.delete_policy.${policy}`), - } - })} - /> - + }))} + /> + + + {/* 任务列表 */} + + + + + {t("tasks.attr.offline_download.list_title")} + + + }> + + {(task) => } + + + {t("tasks.attr.offline_download.no_tasks")} + + + + + + + } - onSubmit={async (urls) => { - const resp = await ok( - pathname(), - urls.split("\n"), - tool(), - deletePolicy(), - ) - handleRespWithNotifySuccess(resp, () => { - onClose() - }) - }} + onSubmitWithValue={handleSubmit} /> ) } diff --git a/src/pages/home/toolbar/TaskProgress.tsx b/src/pages/home/toolbar/TaskProgress.tsx new file mode 100644 index 000000000..11878b2ad --- /dev/null +++ b/src/pages/home/toolbar/TaskProgress.tsx @@ -0,0 +1,140 @@ +import { + VStack, + HStack, + Text, + Badge, + Progress, + ProgressIndicator, +} from "@hope-ui/solid" +import type { TaskInfo } from "~/types" +import { getFileSize } from "~/utils" +import { Show } from "solid-js" +import { useT } from "~/hooks" +import { getPath } from "~/pages/manage/tasks/helper" + +// 解析任务名称,返回文件名和路径信息 +const parseTaskName = (name: string) => { + // download 类型:download 文件名 to (路径) + let match = name.match(/^download (.+) to \((.+)\)$/) + if (match) { + return { + type: "download" as const, + fileName: match[1], + path: match[2], + } + } + // transfer/upload 类型:transfer [设备](路径) to [目标设备](目标路径) 或 upload [文件名](URL) to [目标设备](目标路径) + match = name.match( + /^(transfer|upload) \[(.*?)\]\((.*?)\) to \[(.*?)\]\((.*?)\)$/, + ) + if (match) { + const type = match[1] as "transfer" | "upload" + const bracketContent = match[2] // 方括号内:transfer 为设备,upload 为文件名 + const urlOrPath = match[3] // 圆括号内:transfer 为路径,upload 为 URL + const dstDevice = match[4] + const dstPath = match[5] + + if (type === "transfer") { + // 从路径中提取文件名(最后一段,去除参数) + const fileName = + urlOrPath.split("/").pop()?.split("?")[0] || "Unknown file" + return { + type, + fileName, + srcDevice: bracketContent, + srcPath: urlOrPath, + dstDevice, + dstPath, + } + } else { + // upload 类型:文件名直接取自方括号 + return { + type, + fileName: bracketContent, + srcDevice: "", + srcPath: urlOrPath, + dstDevice, + dstPath, + } + } + } + return null +} + +export const StatusColor = { + 0: "neutral", + 1: "info", + 2: "warning", + 3: "danger", + 4: "success", + 5: "info", +} as const + +export const OfflineDownloadTaskItem = (props: TaskInfo) => { + const t = useT() + const parsed = parseTaskName(props.name) + + return ( + + {parsed ? ( + <> + {parsed.fileName} + {parsed.type === "download" && parsed.path && ( + + {t("tasks.attr.offline_download.path")}:{" "} + {getPath("", parsed.path)} + + )} + {parsed.type === "transfer" && parsed.dstPath && ( + + {t("tasks.attr.offline_download.transfer_dst")}:{" "} + {getPath(parsed.dstDevice, parsed.dstPath)} + + )} + {parsed.type === "upload" && parsed.dstPath && ( + + {t("tasks.attr.offline_download.path")}:{" "} + {getPath(parsed.dstDevice, parsed.dstPath)} + + )} + + ) : ( + {props.name} + )} + + + {t("tasks.state." + props.state)} + + {getFileSize(props.total_bytes)} + + + + + + + {props.error} + + + + ) +} + +export default OfflineDownloadTaskItem diff --git a/src/store/task.ts b/src/store/task.ts new file mode 100644 index 000000000..7c02b8d96 --- /dev/null +++ b/src/store/task.ts @@ -0,0 +1,59 @@ +import { createSignal } from "solid-js" +import { createStore } from "solid-js/store" +import { r } from "~/utils" +import type { TaskInfo } from "~/types" + +const [tasks, setTasks] = createStore([]) +const [loading, setLoading] = createSignal(false) + +export const fetchOfflineDownloadTasks = async (showLoading = true) => { + if (showLoading) setLoading(true) + try { + const [respOld, respNew] = await Promise.all([ + r.get("/task/offline_download/undone"), + r.get("/task/offline_download_transfer/undone"), + ]) + const getTasksFromResp = (resp: any, label: string): any[] => { + if (!resp || resp.code !== 200) { + const message = + resp && typeof resp.message === "string" + ? resp.message + : "Unknown error" + throw new Error(`Failed to fetch ${label}: ${message}`) + } + const data = resp.data + return Array.isArray(data) ? data : [] + } + const taskMap = new Map() + const oldTasks = getTasksFromResp(respOld, "offline download tasks") + oldTasks.forEach((item: any) => { + if (!item.state) item.state = 0 + taskMap.set(item.id, item) + }) + const newTasks = getTasksFromResp( + respNew, + "offline download transfer tasks", + ) + newTasks.forEach((item: any) => { + taskMap.set(item.id, item) + }) + + const mergedTasks = Array.from(taskMap.values()) + + // 按 start_time 降序排序(最新的在前),null 值视为最旧,排在后面 + mergedTasks.sort((a, b) => { + if (!a.start_time && !b.start_time) return 0 + if (!a.start_time) return 1 + if (!b.start_time) return -1 + return b.start_time.localeCompare(a.start_time) // 字符串降序比较 + }) + + setTasks(mergedTasks) + } catch (e) { + console.error("Failed to fetch tasks:", e) + } finally { + if (showLoading) setLoading(false) + } +} + +export const useTasks = () => ({ tasks, loading, fetchOfflineDownloadTasks })