diff --git a/.gitignore b/.gitignore
index 35b0b2ee2..2a319fcd7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ dist
!/src/lang/en/
.DS_Store
.idea
-.test
\ No newline at end of file
+.test
+.codebuddy
diff --git a/src/lang/en/home.json b/src/lang/en/home.json
index c8a2243e2..ce944390d 100644
--- a/src/lang/en/home.json
+++ b/src/lang/en/home.json
@@ -92,6 +92,36 @@
"cancel_select": "Cancel Select",
"offline_download": "Offline download",
"offline_download-tips": "One URL per line",
+ "offline_download_torrent": "BT Offline Download",
+ "offline_download_enhanced": {
+ "tab_link": "Link Download",
+ "tab_bt": "BT Download",
+ "link_placeholder": "Enter download links, one per line\nSupports: HTTP/HTTPS, magnet:?, ed2k://",
+ "link_tips": "Supports HTTP/HTTPS URLs, magnet links, and ed2k links. One link per line.",
+ "drop_torrent": "Drop .torrent file here",
+ "click_to_select": "Or click to select a .torrent file",
+ "parsing": "Parsing torrent file...",
+ "torrent_too_large": "Torrent file is too large (max 10MB)",
+ "parse_failed": "Failed to parse torrent file",
+ "files_count": "files",
+ "select_all": "Select All",
+ "save_path": "Save Path",
+ "download_tool": "Download Tool",
+ "delete_policy": "Delete Policy",
+ "start_download": "Start Download",
+ "rapid_upload_and_download": "Try Rapid Upload",
+ "rapid_upload_success": "Rapid upload succeeded!",
+ "cas_supported": "CAS Rapid Upload",
+ "cas_hint": "This torrent contains CAS info, will try rapid upload to Cloud189 first.",
+ "cas_rapid_upload_mode": "CAS info detected. Will directly use Cloud189 rapid upload (no download tool needed).",
+ "cas_rapid_upload_failed": "CAS rapid upload failed. Please check if the save path is a Cloud189 storage, or try downloading manually.",
+ "cas_failed_fallback_hint": "CAS rapid upload failed. You can now select a download tool and start a normal offline download.",
+ "no_cas_hint": "No CAS info found. Files will be downloaded first, then uploaded.",
+ "reselect": "Reselect",
+ "simplehttp_not_supported": "SimpleHttp does not support BT/magnet downloads. Please select aria2 or another tool.",
+ "ed2k_tool_hint": "ed2k links detected. aria2/qBittorrent do not support ed2k protocol. The system will automatically try Thunder tools, or you can manually select Thunder/ThunderX/ThunderBrowser.",
+ "file_selection_hint": "Note: The torrent file list is for reference only. Partial file selection is not yet supported — all files in the torrent will be downloaded."
+ },
"delete_policy": {
"delete_on_upload_succeed": "Delete on upload succeed",
"delete_on_upload_failed": "Delete on upload failed",
diff --git a/src/pages/home/folder/context-menu.tsx b/src/pages/home/folder/context-menu.tsx
index c5e0c2fe3..af466c33a 100644
--- a/src/pages/home/folder/context-menu.tsx
+++ b/src/pages/home/folder/context-menu.tsx
@@ -4,7 +4,7 @@ import "solid-contextmenu/dist/style.css"
import { HStack, Icon, Text, useColorMode, Image } from "@hope-ui/solid"
import { operations } from "../toolbar/operations"
import { For, Show } from "solid-js"
-import { bus, convertURL, notify } from "~/utils"
+import { bus, convertURL, notify, torrentParse } from "~/utils"
import { ObjType, UserMethods } from "~/types"
import {
getSettingBool,
@@ -18,6 +18,7 @@ import {
import { players } from "../previews/video_box"
import { BsPlayCircleFill } from "solid-icons/bs"
import { isArchive } from "~/store/archive"
+import axios from "axios"
const ItemContent = (props: { name: string }) => {
const t = useT()
@@ -88,6 +89,51 @@ export const ContextMenu = () => {
>
+ - {
+ return (
+ isShare() ||
+ !userCan("offline_download") ||
+ !objStore.write ||
+ !oneChecked() ||
+ selectedObjs().some((o) => o.is_dir) ||
+ !selectedObjs().every((o) =>
+ o.name.toLowerCase().endsWith(".torrent"),
+ )
+ )
+ }}
+ onClick={async () => {
+ const obj = selectedObjs()[0]
+ if (!obj) return
+ try {
+ // 获取 torrent 文件的下载链接并下载内容
+ const link = rawLink(obj, false)
+ const resp = await axios.get(link, { responseType: "arraybuffer" })
+ const buffer = resp.data as ArrayBuffer
+ const bytes = new Uint8Array(buffer)
+ let binary = ""
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i])
+ }
+ const base64Data = btoa(binary)
+
+ // 调用解析 API
+ const parseResp = await torrentParse(base64Data)
+ if (parseResp.code === 200) {
+ bus.emit("torrent_parsed", {
+ torrentData: base64Data,
+ info: parseResp.data,
+ })
+ } else {
+ notify.error(parseResp.message || "解析 torrent 失败")
+ }
+ } catch (err) {
+ notify.error(`解析 torrent 失败: ${err}`)
+ }
+ }}
+ >
+
+
- {
diff --git a/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx b/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx
new file mode 100644
index 000000000..20d71331e
--- /dev/null
+++ b/src/pages/home/toolbar/OfflineDownloadEnhanced.tsx
@@ -0,0 +1,747 @@
+import {
+ Modal,
+ ModalOverlay,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ Button,
+ Textarea,
+ Box,
+ HStack,
+ VStack,
+ Text,
+ Badge,
+ Heading,
+ createDisclosure,
+ notificationService,
+} from "@hope-ui/solid"
+import { SelectWrapper, FolderChooseInput } from "~/components"
+import { useFetch, useRouter, useT } from "~/hooks"
+import {
+ offlineDownload,
+ torrentParse,
+ torrentRapidUpload,
+ bus,
+ handleRespWithNotifySuccess,
+ handleResp,
+ r,
+} from "~/utils"
+import {
+ createSignal,
+ onCleanup,
+ onMount,
+ Show,
+ createMemo,
+ createEffect,
+} from "solid-js"
+import { PResp, TorrentInfo } from "~/types"
+import bencode from "bencode"
+import crypto from "crypto-js"
+import { TorrentFileList } from "./TorrentFileList"
+
+const deletePolicies = [
+ "upload_download_stream",
+ "delete_on_upload_succeed",
+ "delete_on_upload_failed",
+ "delete_never",
+ "delete_always",
+] as const
+
+type DeletePolicy = (typeof deletePolicies)[number]
+
+// Tab 类型
+type TabType = "link" | "bt"
+
+function utf8Decode(data: Uint8Array): string {
+ return crypto.enc.Utf8.stringify(crypto.lib.WordArray.create(data))
+}
+
+function toMagnetUrl(torrentBuffer: Uint8Array) {
+ const data = bencode.decode(torrentBuffer as any)
+ const infoEncode = bencode.encode(data.info) as unknown as Uint8Array
+ const infoHash = crypto
+ .SHA1(crypto.lib.WordArray.create(infoEncode))
+ .toString()
+ let params = {} as any
+ if (Number.isInteger(data?.info?.length)) {
+ params.xl = data.info.length
+ }
+ if (data.info.name) {
+ params.dn = utf8Decode(data.info.name)
+ }
+ if (data.announce) {
+ params.tr = utf8Decode(data.announce)
+ }
+ const paramStr = new URLSearchParams(params).toString()
+ return `magnet:?xt=urn:btih:${infoHash}${paramStr ? "&" + paramStr : ""}`
+}
+
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer)
+ let binary = ""
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i])
+ }
+ return btoa(binary)
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return "0 B"
+ const k = 1024
+ const sizes = ["B", "KB", "MB", "GB", "TB"]
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
+}
+
+export const OfflineDownloadEnhanced = () => {
+ const t = useT()
+ const { pathname } = useRouter()
+
+ // 下载工具列表
+ const [tools, setTools] = createSignal([] as string[])
+ const [toolsLoading, reqTool] = useFetch((): PResp => {
+ return r.get("/public/offline_download_tools")
+ })
+ const [tool, setTool] = createSignal("")
+ const [deletePolicy, setDeletePolicy] = createSignal(
+ "upload_download_stream",
+ )
+
+ // 对话框状态
+ const { isOpen, onOpen, onClose } = createDisclosure()
+ const [activeTab, setActiveTab] = createSignal("link")
+
+ // 链接下载状态
+ const [linkValue, setLinkValue] = createSignal("")
+ const [linkLoading, submitLink] = useFetch(offlineDownload)
+
+ // BT 下载状态
+ const [torrentInfo, setTorrentInfo] = createSignal(null)
+ const [torrentData, setTorrentData] = createSignal("") // Base64 编码
+ const [selectedFiles, setSelectedFiles] = createSignal([])
+ const [btLoading, setBtLoading] = createSignal(false)
+ const [parsing, setParsing] = createSignal(false)
+
+ // 保存路径
+ const [savePath, setSavePath] = createSignal("")
+
+ // 秒传状态
+ const [rapidUploading, setRapidUploading] = createSignal(false)
+ const [rapidUploadResult, setRapidUploadResult] = createSignal("")
+ // 秒传失败后允许回退到普通离线下载
+ const [casRapidUploadFailed, setCasRapidUploadFailed] =
+ createSignal(false)
+
+ // 检测输入中是否包含 ed2k 链接
+ const hasEd2kLinks = createMemo(() => {
+ return linkValue()
+ .split("\n")
+ .some((line) => line.trim().toLowerCase().startsWith("ed2k://"))
+ })
+
+ // 当有 CAS 信息且秒传尚未失败时,默认使用天翼云秒传(不需要 aria2)
+ const shouldUseCasRapidUpload = createMemo(() => {
+ return (
+ activeTab() === "bt" &&
+ !!torrentInfo()?.has_cas &&
+ !casRapidUploadFailed()
+ )
+ })
+
+ // 检测输入中是否包含磁力链
+ const hasMagnetLinks = createMemo(() => {
+ return linkValue()
+ .split("\n")
+ .some((line) => line.trim().toLowerCase().startsWith("magnet:?"))
+ })
+
+ // 是否应该禁用 SimpleHttp(BT种子/磁力链/ed2k 场景不支持)
+ const shouldDisableSimpleHttp = createMemo(() => {
+ return activeTab() === "bt" || hasEd2kLinks() || hasMagnetLinks()
+ })
+
+ // 可用的工具列表(根据场景过滤)
+ const availableTools = createMemo(() => {
+ if (shouldDisableSimpleHttp()) {
+ return tools().filter((t) => t !== "SimpleHttp")
+ }
+ return tools()
+ })
+
+ // 当 SimpleHttp 被禁用时,自动切换到第一个可用工具
+ createEffect(() => {
+ if (shouldDisableSimpleHttp() && tool() === "SimpleHttp") {
+ const available = availableTools()
+ if (available.length > 0) {
+ setTool(available[0])
+ }
+ }
+ })
+
+ onMount(async () => {
+ const resp = await reqTool()
+ handleResp(resp, (data) => {
+ setTools(data)
+ setTool(data[0])
+ })
+ })
+
+ // 监听 bus 事件
+ const handler = (name: string) => {
+ if (name === "offline_download") {
+ setSavePath(pathname())
+ onOpen()
+ }
+ }
+ bus.on("tool", handler)
+ onCleanup(() => {
+ bus.off("tool", handler)
+ })
+
+ // 监听从右键菜单触发的 torrent 解析事件
+ const torrentHandler = (data: { torrentData: string; info: TorrentInfo }) => {
+ setTorrentData(data.torrentData)
+ setTorrentInfo(data.info)
+ setSelectedFiles(data.info.files.map((_, i) => i))
+ setActiveTab("bt")
+ setSavePath(pathname())
+ onOpen()
+ }
+ bus.on("torrent_parsed", torrentHandler)
+ onCleanup(() => {
+ bus.off("torrent_parsed", torrentHandler)
+ })
+
+ // 重置状态
+ const resetState = () => {
+ setLinkValue("")
+ setTorrentInfo(null)
+ setTorrentData("")
+ setSelectedFiles([])
+ setRapidUploadResult("")
+ setCasRapidUploadFailed(false)
+ }
+
+ const handleClose = () => {
+ resetState()
+ onClose()
+ }
+
+ // 处理 torrent 文件拖拽/选择
+ const handleTorrentFile = async (file: File) => {
+ if (file.size > 10 * 1024 * 1024) {
+ notificationService.show({
+ status: "danger",
+ title: t("home.toolbar.offline_download_enhanced.torrent_too_large"),
+ })
+ return
+ }
+
+ setParsing(true)
+ try {
+ const buffer = await file.arrayBuffer()
+ const base64Data = arrayBufferToBase64(buffer)
+
+ const resp = await torrentParse(base64Data)
+ handleResp(resp, (data) => {
+ setTorrentInfo(data)
+ setTorrentData(base64Data)
+ setSelectedFiles(data.files.map((_, i) => i))
+ })
+ } catch (err) {
+ notificationService.show({
+ status: "danger",
+ title: t("home.toolbar.offline_download_enhanced.parse_failed"),
+ description: String(err),
+ })
+ } finally {
+ setParsing(false)
+ }
+ }
+
+ // 拖拽处理
+ const handleDrop = (e: DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (!e.dataTransfer?.files.length) return
+
+ if (activeTab() === "bt") {
+ // BT Tab: 解析第一个 torrent 文件
+ for (const file of e.dataTransfer.files) {
+ if (file.name.toLowerCase().endsWith(".torrent")) {
+ handleTorrentFile(file)
+ return
+ }
+ }
+ } else {
+ // Link Tab: 将 torrent 文件转换为磁力链追加到输入框
+ const processFiles = async () => {
+ const values: string[] = []
+ for (const file of e.dataTransfer!.files) {
+ if (file.name.toLowerCase().endsWith(".torrent")) {
+ try {
+ const buffer = await file.arrayBuffer()
+ values.push(toMagnetUrl(new Uint8Array(buffer)))
+ } catch (err) {
+ console.error("Failed to convert torrent:", err)
+ }
+ }
+ }
+ if (values.length) {
+ setLinkValue((prev) =>
+ prev ? prev + "\n" + values.join("\n") : values.join("\n"),
+ )
+ }
+ }
+ processFiles()
+ }
+ }
+
+ // 文件选择处理
+ const handleFileSelect = () => {
+ const input = document.createElement("input")
+ input.type = "file"
+ input.accept = ".torrent"
+ input.onchange = (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (file) {
+ handleTorrentFile(file)
+ }
+ }
+ input.click()
+ }
+
+ // 提交链接下载
+ const handleLinkSubmit = async () => {
+ if (!linkValue().trim()) return
+ const urls = linkValue()
+ .split("\n")
+ .filter((u) => u.trim())
+ const resp = await submitLink(savePath(), urls, tool(), deletePolicy())
+ handleRespWithNotifySuccess(resp, () => {
+ handleClose()
+ })
+ }
+
+ // 提交 BT 下载
+ const handleBtSubmit = async () => {
+ const info = torrentInfo()
+ if (!info || !torrentData()) return
+
+ setBtLoading(true)
+ try {
+ // 有 CAS 信息且秒传尚未失败时,默认直接走天翼云秒传
+ if (info.has_cas && !casRapidUploadFailed()) {
+ setRapidUploading(true)
+ try {
+ const resp = await torrentRapidUpload(torrentData(), savePath())
+ if (resp.code === 200) {
+ setRapidUploadResult("success")
+ notificationService.show({
+ status: "success",
+ title: t(
+ "home.toolbar.offline_download_enhanced.rapid_upload_success",
+ ),
+ description: resp.data.file_name,
+ })
+ handleClose()
+ return
+ } else {
+ // 秒传失败,标记允许回退到普通离线下载
+ setCasRapidUploadFailed(true)
+ notificationService.show({
+ status: "warning",
+ title: t(
+ "home.toolbar.offline_download_enhanced.cas_rapid_upload_failed",
+ ),
+ description: resp.message,
+ })
+ }
+ } catch (err) {
+ // 秒传异常,标记允许回退到普通离线下载
+ setCasRapidUploadFailed(true)
+ notificationService.show({
+ status: "danger",
+ title: t(
+ "home.toolbar.offline_download_enhanced.cas_rapid_upload_failed",
+ ),
+ description: String(err),
+ })
+ } finally {
+ setRapidUploading(false)
+ }
+ // 秒传失败后返回,让用户选择是否继续普通离线下载
+ return
+ }
+
+ // 无 CAS 信息或秒传失败后,走正常离线下载流程
+ // SimpleHttp 不支持磁力链/BT 下载
+ if (tool() === "SimpleHttp") {
+ notificationService.show({
+ status: "warning",
+ title: t(
+ "home.toolbar.offline_download_enhanced.simplehttp_not_supported",
+ ),
+ })
+ return
+ }
+
+ // 正常离线下载:将 torrent 转为磁力链提交
+ const buffer = Uint8Array.from(atob(torrentData()), (c) =>
+ c.charCodeAt(0),
+ )
+ const magnetUrl = toMagnetUrl(buffer)
+ const resp = await offlineDownload(
+ savePath(),
+ [magnetUrl],
+ tool(),
+ deletePolicy(),
+ )
+ handleRespWithNotifySuccess(resp, () => {
+ handleClose()
+ })
+ } finally {
+ setBtLoading(false)
+ }
+ }
+
+ return (
+
+
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ onDrop={handleDrop}
+ >
+ {t("home.toolbar.offline_download")}
+
+ {/* Tab 切换 */}
+
+
+
+
+
+ {/* 链接下载 Tab */}
+
+
+
+
+
+ {/* BT 下载 Tab */}
+
+
+ {/* 未解析时显示上传区域 */}
+
+
+
+
+ {t(
+ "home.toolbar.offline_download_enhanced.drop_torrent",
+ )}
+
+
+ {t(
+ "home.toolbar.offline_download_enhanced.click_to_select",
+ )}
+
+
+ }
+ >
+
+ {t("home.toolbar.offline_download_enhanced.parsing")}
+
+
+
+
+
+ {/* 已解析时显示文件列表 */}
+
+
+ {/* 种子信息头部 */}
+
+
+
+ {torrentInfo()!.name}
+
+
+
+ {formatFileSize(torrentInfo()!.total_size)}
+
+
+ {torrentInfo()!.files.length}{" "}
+ {t(
+ "home.toolbar.offline_download_enhanced.files_count",
+ )}
+
+
+
+
+
+
+ {t(
+ "home.toolbar.offline_download_enhanced.cas_supported",
+ )}
+
+
+
+
+
+
+ {/* 文件列表 */}
+
+
+ {t(
+ "home.toolbar.offline_download_enhanced.file_selection_hint",
+ )}
+
+
+
+
+
+
+ {/* 公共选项区域 */}
+
+ {/* 保存路径 */}
+
+
+ {t("home.toolbar.offline_download_enhanced.save_path")}
+
+
+
+
+ {/* 下载工具选择(CAS 秒传模式下隐藏) */}
+
+
+
+ {t("home.toolbar.offline_download_enhanced.download_tool")}
+
+ {
+ if (
+ v !== "SimpleHttp" &&
+ deletePolicy() === "upload_download_stream"
+ ) {
+ setDeletePolicy("delete_on_upload_succeed")
+ }
+ setTool(v)
+ }}
+ options={availableTools().map((t) => ({
+ value: t,
+ label: t,
+ }))}
+ />
+
+
+ {t(
+ "home.toolbar.offline_download_enhanced.simplehttp_not_supported",
+ )}
+
+
+
+
+ {/* 删除策略 */}
+
+
+ {t("home.toolbar.offline_download_enhanced.delete_policy")}
+
+ 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}`),
+ }))}
+ />
+
+
+
+ {/* CAS 秒传提示 */}
+
+
+
+ {t(
+ "home.toolbar.offline_download_enhanced.cas_rapid_upload_mode",
+ )}
+
+
+
+
+ {/* CAS 秒传失败后,提示用户可继续普通离线下载 */}
+
+
+
+ {t(
+ "home.toolbar.offline_download_enhanced.cas_failed_fallback_hint",
+ )}
+
+
+
+
+
+
+
+ {t("home.toolbar.offline_download_enhanced.no_cas_hint")}
+
+
+
+
+ {/* ed2k 链接工具提示 */}
+
+
+
+ {t("home.toolbar.offline_download_enhanced.ed2k_tool_hint")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/home/toolbar/Toolbar.tsx b/src/pages/home/toolbar/Toolbar.tsx
index 6ab28b572..e55e19b2e 100644
--- a/src/pages/home/toolbar/Toolbar.tsx
+++ b/src/pages/home/toolbar/Toolbar.tsx
@@ -9,7 +9,7 @@ import { Mkdir } from "./Mkdir"
import { RecursiveMove } from "./RecursiveMove"
import { RemoveEmptyDirectory } from "./RemoveEmptyDirectory"
import { BatchRename } from "./BatchRename"
-import { OfflineDownload } from "./OfflineDownload"
+import { OfflineDownloadEnhanced } from "./OfflineDownloadEnhanced"
import { PackageDownloadModal } from "./Download"
import { lazy } from "solid-js"
import { ModalWrapper } from "./ModalWrapper"
@@ -34,7 +34,7 @@ export const Modal = () => {
-
+
diff --git a/src/pages/home/toolbar/TorrentFileList.tsx b/src/pages/home/toolbar/TorrentFileList.tsx
new file mode 100644
index 000000000..fbf224fb4
--- /dev/null
+++ b/src/pages/home/toolbar/TorrentFileList.tsx
@@ -0,0 +1,302 @@
+import { Box, Checkbox, HStack, Text, VStack, Button } from "@hope-ui/solid"
+import { createSignal, For, Show, createMemo } from "solid-js"
+import { TorrentFile } from "~/types"
+import { useT } from "~/hooks"
+
+// 树节点类型
+interface TreeNode {
+ name: string
+ path: string
+ isDir: boolean
+ size: number
+ children: TreeNode[]
+ fileIndex?: number // 对应 files 数组中的索引(仅叶子节点)
+ allIndices?: number[] // 目录下所有文件索引(仅目录节点,构建时缓存)
+}
+
+// 将扁平文件列表构建为树形结构
+function buildFileTree(files: TorrentFile[]): TreeNode[] {
+ const root: TreeNode = {
+ name: "",
+ path: "",
+ isDir: true,
+ size: 0,
+ children: [],
+ }
+
+ files.forEach((file, index) => {
+ const parts = file.path.split("/")
+ let current = root
+
+ parts.forEach((part, i) => {
+ if (i === parts.length - 1) {
+ // 叶子节点(文件)
+ current.children.push({
+ name: part,
+ path: file.path,
+ isDir: false,
+ size: file.size,
+ children: [],
+ fileIndex: index,
+ })
+ } else {
+ // 目录节点
+ let dirNode = current.children.find((c) => c.isDir && c.name === part)
+ if (!dirNode) {
+ dirNode = {
+ name: part,
+ path: parts.slice(0, i + 1).join("/"),
+ isDir: true,
+ size: 0,
+ children: [],
+ }
+ current.children.push(dirNode)
+ }
+ current = dirNode
+ }
+ })
+ })
+
+ // 计算目录大小并缓存目录的所有子文件索引
+ function calcDirMetadata(node: TreeNode): number {
+ if (!node.isDir) return node.size
+ node.size = node.children.reduce(
+ (sum, child) => sum + calcDirMetadata(child),
+ 0,
+ )
+ node.allIndices = getAllFileIndices(node)
+ return node.size
+ }
+ root.children.forEach(calcDirMetadata)
+
+ // 如果只有一个根目录,直接返回其子节点
+ if (root.children.length === 1 && root.children[0].isDir) {
+ return root.children
+ }
+ return root.children
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return "0 B"
+ const k = 1024
+ const sizes = ["B", "KB", "MB", "GB", "TB"]
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
+}
+
+// 获取目录下所有文件的索引
+function getAllFileIndices(node: TreeNode): number[] {
+ if (!node.isDir) {
+ return node.fileIndex !== undefined ? [node.fileIndex] : []
+ }
+ return node.children.flatMap(getAllFileIndices)
+}
+
+export interface TorrentFileListProps {
+ files: TorrentFile[]
+ selectedFiles: number[]
+ onSelectionChange: (selected: number[]) => void
+}
+
+export const TorrentFileList = (props: TorrentFileListProps) => {
+ const t = useT()
+
+ const tree = createMemo(() => buildFileTree(props.files))
+
+ // 使用 Set 加速成员检测
+ const selectedSet = createMemo(() => new Set(props.selectedFiles))
+
+ const allSelected = createMemo(
+ () => props.selectedFiles.length === props.files.length,
+ )
+ const noneSelected = createMemo(() => props.selectedFiles.length === 0)
+
+ const toggleAll = () => {
+ if (allSelected()) {
+ props.onSelectionChange([])
+ } else {
+ props.onSelectionChange(props.files.map((_, i) => i))
+ }
+ }
+
+ const toggleFile = (index: number) => {
+ const set = selectedSet()
+ if (set.has(index)) {
+ props.onSelectionChange(props.selectedFiles.filter((i) => i !== index))
+ } else {
+ props.onSelectionChange([...props.selectedFiles, index])
+ }
+ }
+
+ const toggleDir = (node: TreeNode) => {
+ const indices = node.allIndices ?? getAllFileIndices(node)
+ const set = selectedSet()
+ const allChecked = indices.every((i) => set.has(i))
+ if (allChecked) {
+ const removeSet = new Set(indices)
+ props.onSelectionChange(
+ props.selectedFiles.filter((i) => !removeSet.has(i)),
+ )
+ } else {
+ const newSelection = [...new Set([...props.selectedFiles, ...indices])]
+ props.onSelectionChange(newSelection)
+ }
+ }
+
+ return (
+
+ {/* 全选/统计 */}
+
+
+
+ {t("home.toolbar.offline_download_enhanced.select_all")} (
+ {props.selectedFiles.length}/{props.files.length})
+
+
+
+ {formatFileSize(
+ props.selectedFiles.reduce(
+ (sum, i) => sum + (props.files[i]?.size || 0),
+ 0,
+ ),
+ )}
+
+
+
+ {/* 文件树 */}
+
+
+ {(node) => (
+
+ )}
+
+
+
+ )
+}
+
+// 树节点组件
+function TreeNodeItem(props: {
+ node: TreeNode
+ selectedSet: Set
+ onToggleFile: (index: number) => void
+ onToggleDir: (node: TreeNode) => void
+ depth: number
+}) {
+ const [expanded, setExpanded] = createSignal(true)
+
+ const isChecked = createMemo(() => {
+ if (!props.node.isDir) {
+ return props.selectedSet.has(props.node.fileIndex!)
+ }
+ const indices = props.node.allIndices ?? getAllFileIndices(props.node)
+ return indices.length > 0 && indices.every((i) => props.selectedSet.has(i))
+ })
+
+ const isIndeterminate = createMemo(() => {
+ if (!props.node.isDir) return false
+ const indices = props.node.allIndices ?? getAllFileIndices(props.node)
+ const checkedCount = indices.filter((i) => props.selectedSet.has(i)).length
+ return checkedCount > 0 && checkedCount < indices.length
+ })
+
+ return (
+
+
+ {/* 展开/折叠按钮 */}
+
+ setExpanded(!expanded())}
+ w="16px"
+ textAlign="center"
+ fontSize="$xs"
+ color="$neutral10"
+ flexShrink={0}
+ >
+ {expanded() ? "▼" : "▶"}
+
+
+
+
+
+
+ {/* 复选框 */}
+ {
+ if (props.node.isDir) {
+ props.onToggleDir(props.node)
+ } else {
+ props.onToggleFile(props.node.fileIndex!)
+ }
+ }}
+ />
+
+ {/* 图标 */}
+
+ {props.node.isDir ? "📁" : "📄"}
+
+
+ {/* 文件名 */}
+
+ {props.node.name}
+
+
+ {/* 文件大小 */}
+
+ {formatFileSize(props.node.size)}
+
+
+
+ {/* 子节点 */}
+
+
+ {(child) => (
+
+ )}
+
+
+
+ )
+}
diff --git a/src/pages/home/toolbar/operations.ts b/src/pages/home/toolbar/operations.ts
index 2f50c4976..397f39524 100644
--- a/src/pages/home/toolbar/operations.ts
+++ b/src/pages/home/toolbar/operations.ts
@@ -8,6 +8,7 @@ import { CgFileAdd, CgFolderAdd, CgFolderRemove } from "solid-icons/cg"
import { AiOutlineCloudDownload } from "solid-icons/ai"
import { ImMoveUp } from "solid-icons/im"
import { BiRegularRename } from "solid-icons/bi"
+import { FaSolidMagnet } from "solid-icons/fa"
export interface Operations {
[key: string]: {
@@ -31,6 +32,7 @@ export const operations: Operations = {
cancel_select: { icon: TiDeleteOutline },
download: { icon: AiOutlineCloudDownload, color: "$primary9" },
share: { icon: CgShare, color: "$primary9" },
+ offline_download_torrent: { icon: FaSolidMagnet, color: "$accent9" },
}
// interface Operation {
// label: string;
diff --git a/src/types/index.ts b/src/types/index.ts
index 52f45e97b..ee6a1a437 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -8,3 +8,4 @@ export * from "./item_type"
export * from "./meta"
export * from "./task"
export * from "./share"
+export * from "./torrent"
diff --git a/src/types/torrent.ts b/src/types/torrent.ts
new file mode 100644
index 000000000..e2e04b4bd
--- /dev/null
+++ b/src/types/torrent.ts
@@ -0,0 +1,38 @@
+// Torrent 文件中的单个文件信息
+export interface TorrentFile {
+ path: string
+ size: number
+}
+
+// CAS 扩展信息(天翼云秒传)
+export interface CASInfo {
+ file_md5: string
+ slice_md5: string
+ slice_size: number
+ cloud: string
+}
+
+// Torrent 解析结果
+export interface TorrentInfo {
+ name: string
+ total_size: number
+ piece_length: number
+ piece_count: number
+ info_hash: string
+ files: TorrentFile[]
+ has_cas: boolean
+ cas?: CASInfo
+}
+
+// 上传解析 torrent 的响应
+export interface TorrentUploadParseResult {
+ info: TorrentInfo
+ torrent_data: string // Base64 编码的 torrent 数据
+}
+
+// 秒传结果
+export interface TorrentRapidUploadResult {
+ message: string
+ file_name: string
+ file_size: number
+}
diff --git a/src/utils/api.ts b/src/utils/api.ts
index 5af0a70bc..c51bb69cb 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -9,6 +9,9 @@ import {
RenameObj,
ArchiveMeta,
PPageResp,
+ TorrentInfo,
+ TorrentUploadParseResult,
+ TorrentRapidUploadResult,
} from "~/types"
import { r } from "."
@@ -281,3 +284,24 @@ export const updateIndex = async (paths = [], max_depth = -1): PEmptyResp => {
max_depth,
})
}
+
+// ========== Torrent 相关 API ==========
+
+export const torrentParse = (torrent_data: string): PResp => {
+ return r.post("/fs/torrent/parse", { torrent_data })
+}
+
+export const torrentUploadParse = (
+ file: File,
+): PResp => {
+ const formData = new FormData()
+ formData.append("torrent", file)
+ return r.post("/fs/torrent/upload_parse", formData)
+}
+
+export const torrentRapidUpload = (
+ torrent_data: string,
+ path: string,
+): PResp => {
+ return r.post("/fs/torrent/rapid_upload", { torrent_data, path })
+}
diff --git a/src/utils/bus.ts b/src/utils/bus.ts
index d8f2a99a4..ac815de71 100644
--- a/src/utils/bus.ts
+++ b/src/utils/bus.ts
@@ -1,4 +1,5 @@
import mitt from "mitt"
+import { TorrentInfo } from "~/types"
type Events = {
to: string
@@ -6,6 +7,7 @@ type Events = {
tool: string
pathname: string
extract: string
+ torrent_parsed: { torrentData: string; info: TorrentInfo }
}
export const bus = mitt()