Skip to content
2 changes: 2 additions & 0 deletions 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 @@ -92,6 +93,7 @@ export const ModalInput = (props: ModalInputProps) => {
}
}
props.onSubmit?.(value())
props.onSubmitWithValue?.(value(), setValue)
Comment thread
xrgzs marked this conversation as resolved.
Outdated
}

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
162 changes: 162 additions & 0 deletions src/pages/home/offlinedownload/TaskProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// src/pages/home/toolbar/TaskProgress.tsx
Comment thread
xrgzs marked this conversation as resolved.
Outdated
import {
VStack,
HStack,
Text,
Badge,
Progress,
ProgressIndicator,
} from "@hope-ui/solid"
import { TaskInfo } from "./task"
import { getFileSize } from "~/utils"
import { Show, createSignal, JSX } from "solid-js"
import { useT } from "~/hooks"
import { me } from "~/store"

// 复制 helper.tsx 中的 getPath 函数(用于生成路径链接)
const getPath = (
device: string,
path: string,
asLink: boolean = true,
): 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 && asLink ? (
<a
style={underline() ? "text-decoration: underline" : ""}
onMouseOver={() => setUnderline(true)}
onMouseOut={() => setUnderline(false)}
href={fullPath.slice(prefix.length)}
>
{fullPath}
</a>
) : (
<p>{fullPath}</p>
Comment thread
xrgzs marked this conversation as resolved.
Outdated
)
}

// 解析任务名称,返回文件名和路径信息
const parseTaskName = (name: string) => {
Comment thread
xrgzs marked this conversation as resolved.
Outdated
// 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] || "未知文件"
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 TaskItem = (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]}
>
{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 * 100}
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 TaskItem
64 changes: 64 additions & 0 deletions src/pages/home/offlinedownload/task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// src/store/task.ts
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { r } from "~/utils"

export interface TaskInfo {
id: string
name: string
creator: string
creator_role: number
state: number
status: string
progress: number
start_time: string | null
end_time: string | null
total_bytes: number
error: string
}

Comment thread
xrgzs marked this conversation as resolved.
Outdated
const [tasks, setTasks] = createStore<TaskInfo[]>([])
const [loading, setLoading] = createSignal(false)

export const fetchTasks = async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
const [respOld, respNew] = await Promise.all([
r.get("/task/offline_download/undone").catch(() => ({ data: [] })),
r
.get("/task/offline_download_transfer/undone")
.catch(() => ({ data: [] })),
])

const taskMap = new Map<string, TaskInfo>()

const oldTasks = respOld.data || []
oldTasks.forEach((item: any) => {
if (!item.state) item.state = 0
taskMap.set(item.id, item)
})

const newTasks = respNew.data || []
Comment thread
xrgzs marked this conversation as resolved.
Outdated
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, fetchTasks })
Loading