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