Skip to content
7 changes: 6 additions & 1 deletion src/components/ModalInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion src/lang/en/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 123 additions & 37 deletions src/pages/home/toolbar/OfflineDownload.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -72,17 +88,67 @@ 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)
onCleanup(() => {
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,
Expand Down Expand Up @@ -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}
Expand All @@ -135,36 +204,53 @@ export const OfflineDownload = () => {
</Box>
}
bottomSlot={
<Box mb="$2">
<SelectWrapper
value={deletePolicy()}
onChange={(v) => setDeletePolicy(v as DeletePolicy)}
options={deletePolicies
.filter((policy) =>
policy == "upload_download_stream"
? tool() === "SimpleHttp"
: true,
)
.map((policy) => {
return {
<VStack spacing="$4" w="$full">
<Box>
<SelectWrapper
value={deletePolicy()}
onChange={(v) => 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}`),
}
})}
/>
</Box>
}))}
/>
</Box>

{/* 任务列表 */}
<Show when={showTasks()}>
<Box
w="$full"
maxHeight="300px"
overflowY="auto"
pr="$1" // 避免滚动条遮挡内容
minHeight="0"
>
<HStack justifyContent="space-between" mb="$2">
<Heading size="sm" mb="$2" textAlign="center" w="$full">
{t("tasks.attr.offline_download.list_title")}
</Heading>
</HStack>
<Show when={!tasksLoading()} fallback={<FullLoading />}>
<VStack spacing="$2">
<For each={tasks}>{(task) => <OfflineDownloadTaskItem {...task} />}</For>
<Show when={tasks.length === 0}>
<Text color="$neutral11" textAlign="center" w="$full">
{t("tasks.attr.offline_download.no_tasks")}
</Text>
</Show>
</VStack>
</Show>
</Box>
</Show>
</VStack>
}
onSubmit={async (urls) => {
const resp = await ok(
pathname(),
urls.split("\n"),
tool(),
deletePolicy(),
)
handleRespWithNotifySuccess(resp, () => {
onClose()
})
}}
onSubmitWithValue={handleSubmit}
/>
)
}
140 changes: 140 additions & 0 deletions src/pages/home/toolbar/TaskProgress.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<VStack
w="$full"
spacing="$1"
rounded="$lg"
border="1px solid $neutral7"
alignItems="start"
p="$2"
_hover={{ border: "1px solid $info6" }}
>
{parsed ? (
<>
<Text css={{ wordBreak: "break-all" }}>{parsed.fileName}</Text>
{parsed.type === "download" && parsed.path && (
<Text css={{ wordBreak: "break-all" }} size="sm" color="$neutral11">
{t("tasks.attr.offline_download.path")}:{" "}
{getPath("", parsed.path)}
</Text>
)}
{parsed.type === "transfer" && parsed.dstPath && (
<Text css={{ wordBreak: "break-all" }} size="sm" color="$neutral11">
{t("tasks.attr.offline_download.transfer_dst")}:{" "}
{getPath(parsed.dstDevice, parsed.dstPath)}
</Text>
)}
{parsed.type === "upload" && parsed.dstPath && (
<Text css={{ wordBreak: "break-all" }} size="sm" color="$neutral11">
{t("tasks.attr.offline_download.path")}:{" "}
{getPath(parsed.dstDevice, parsed.dstPath)}
</Text>
)}
</>
) : (
<Text css={{ wordBreak: "break-all" }}>{props.name}</Text>
)}
<HStack spacing="$2" w="$full" justifyContent="space-between">
<Badge
colorScheme={
StatusColor[props.state as keyof typeof StatusColor] ?? "info"
}
>
{t("tasks.state." + props.state)}
</Badge>
<Text color="$neutral11">{getFileSize(props.total_bytes)}</Text>
</HStack>
<Progress
w="$full"
trackColor="$info3"
rounded="$full"
value={props.progress}
size="sm"
>
<ProgressIndicator color="$info6" rounded="$md" />
</Progress>
<Show when={props.error}>
<Text color="$danger10" css={{ wordBreak: "break-all" }}>
{props.error}
</Text>
</Show>
</VStack>
)
}

export default OfflineDownloadTaskItem
Loading
Loading