From 56fa6a8a2d9938fa0b5211861abc2865d9e2e1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=9A=E5=AD=90?= <40852301+uiYzzi@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:43:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E6=B7=BB=E5=8A=A0=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E5=92=8C=E4=B8=8B=E8=BD=BD=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 43 ++++- src-tauri/Cargo.toml | 1 + src-tauri/src/handlers/download.rs | 153 ++++++++++++++++++ src-tauri/src/handlers/file.rs | 22 +++ src-tauri/src/handlers/mod.rs | 4 +- src-tauri/src/lib.rs | 8 + src-tauri/src/models/download.rs | 24 +++ src-tauri/src/models/mod.rs | 3 +- src-tauri/tauri.conf.json | 8 +- src/components/DownloadCard/index.tsx | 61 +++++++ src/components/Sidebar/index.tsx | 4 +- src/components/TitleBar/store.ts | 5 +- src/components/ui/checkbox.tsx | 63 ++++++++ src/components/ui/dialog.tsx | 141 ++++++++++++++++ src/components/ui/input.tsx | 20 +++ src/components/ui/label.tsx | 19 +++ src/components/ui/progress.tsx | 34 ++++ src/components/ui/tabs.tsx | 87 ++++++++++ src/features/app-detail/AppDetail.tsx | 160 ++++++++++++++++++- src/features/collection/CollectionDetail.tsx | 51 ++++++ src/features/collection/Collections.tsx | 37 +++++ src/features/collection/store.ts | 95 +++++++++++ src/features/downloads/Downloads.tsx | 35 ++++ src/features/downloads/store.ts | 75 +++++++++ src/index.tsx | 12 ++ src/lib/api/download.ts | 68 ++++++++ src/lib/api/file.ts | 27 ++++ src/types/collection.ts | 22 +++ src/types/download.ts | 21 +++ 29 files changed, 1284 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/handlers/download.rs create mode 100644 src-tauri/src/handlers/file.rs create mode 100644 src-tauri/src/models/download.rs create mode 100644 src/components/DownloadCard/index.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/features/collection/CollectionDetail.tsx create mode 100644 src/features/collection/Collections.tsx create mode 100644 src/features/collection/store.ts create mode 100644 src/features/downloads/Downloads.tsx create mode 100644 src/features/downloads/store.ts create mode 100644 src/lib/api/download.ts create mode 100644 src/lib/api/file.ts create mode 100644 src/types/collection.ts create mode 100644 src/types/download.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2bb38a2..bc3d89a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -821,7 +821,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -832,10 +841,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.0", + "windows-sys 0.59.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -3168,6 +3189,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.11", +] + [[package]] name = "regex" version = "1.11.1" @@ -3709,6 +3741,7 @@ dependencies = [ name = "spark-store" version = "4.9.9" dependencies = [ + "dirs 6.0.0", "futures", "lazy_static", "pinyin", @@ -3929,7 +3962,7 @@ checksum = "78f6efc261c7905839b4914889a5b25df07f0ff89c63fb4afd6ff8c96af15e4d" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 5.0.1", "dunce", "embed_plist", "futures-util", @@ -3979,7 +4012,7 @@ checksum = "8e950124f6779c6cf98e3260c7a6c8488a74aa6350dd54c6950fdaa349bca2df" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 5.0.1", "glob", "heck 0.5.0", "json-patch", @@ -4501,7 +4534,7 @@ checksum = "d48a05076dd272615d03033bf04f480199f7d1b66a8ac64d75c625fc4a70c06b" dependencies = [ "core-graphics 0.24.0", "crossbeam-channel", - "dirs", + "dirs 5.0.1", "libappindicator", "muda", "objc2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2493120..07dab98 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,3 +29,4 @@ lazy_static = "1.5.0" futures = "0.3.31" pinyin = "0.10.0" tauri-plugin-clipboard-manager = "2.2.0" +dirs = "6.0.0" diff --git a/src-tauri/src/handlers/download.rs b/src-tauri/src/handlers/download.rs new file mode 100644 index 0000000..e3fa047 --- /dev/null +++ b/src-tauri/src/handlers/download.rs @@ -0,0 +1,153 @@ +use crate::models::download::{DownloadStatus, DownloadTask}; +use tauri::State; +use std::sync::Mutex; +use std::collections::HashMap; +use crate::handlers::server::get_json_server_url; +use crate::utils::{format_icon_url, UA}; + +pub type DownloadQueue = Mutex>; + +#[tauri::command] +pub async fn get_downloads(queue: State<'_, DownloadQueue>) -> Result, String> { + let downloads = queue.lock().map_err(|e| e.to_string())?; + Ok(downloads.values().cloned().collect()) +} + +// 检查是否有正在下载的任务 +fn has_downloading_task(downloads: &HashMap) -> bool { + downloads.values().any(|task| matches!(task.status, DownloadStatus::Downloading)) +} + +// 获取下一个等待下载的任务 +fn get_next_queued_task(downloads: &mut HashMap) -> Option { + downloads + .iter() + .find(|(_, task)| matches!(task.status, DownloadStatus::Queued)) + .map(|(task_id, _)| task_id.clone()) +} + +// 开始下载下一个任务 +fn start_next_download(downloads: &mut HashMap) { + if let Some(task_id) = get_next_queued_task(downloads) { + if let Some(task) = downloads.get_mut(&task_id) { + task.status = DownloadStatus::Downloading; + } + } +} + +#[tauri::command] +pub async fn add_download(category: String, pkgname: String, filename:String, name:String, queue: State<'_, DownloadQueue>) -> Result<(), String> { + let task_id = format!("{}/{}", category, pkgname); + + // 检查任务是否已存在 + { + let downloads = queue.lock().map_err(|e| e.to_string())?; + if downloads.contains_key(&task_id) { + return Ok(()); + } + } + + // 获取metalink文件URL + let json_server_url = get_json_server_url(); + let metalink_url = format!("{}{}/{}/{}.metalink", json_server_url, category, pkgname, filename); + // 发送请求获取metalink文件 + let client = reqwest::Client::new(); + let response = client + .get(&metalink_url) + .header("User-Agent", UA) + .send() + .await + .map_err(|e| format!("获取metalink文件失败: {}", e))?; + + // 检查响应状态 + if !response.status().is_success() { + return Err(format!("获取metalink文件失败: HTTP {}", response.status())); + } + + let mut downloads = queue.lock().map_err(|e| e.to_string())?; + let initial_status = if has_downloading_task(&downloads) { + DownloadStatus::Queued + } else { + DownloadStatus::Downloading + }; + + // 创建下载任务 + let task = DownloadTask { + category: category.clone(), + pkgname: pkgname.clone(), + filename, + name, + icon: format_icon_url(&category, &pkgname), + status: initial_status, + progress: 0.0, + speed: None, + size: None, + }; + + // 添加任务到队列 + downloads.insert(task_id, task); + + Ok(()) +} + +#[tauri::command] +pub async fn pause_download(category: String, pkgname: String, queue: State<'_, DownloadQueue>) -> Result<(), String> { + let mut downloads = queue.lock().map_err(|e| e.to_string())?; + let task_id = format!("{}/{}", category, pkgname); + + if let Some(task) = downloads.get_mut(&task_id) { + let old_status = task.status.clone(); + task.status = DownloadStatus::Paused; + if matches!(old_status, DownloadStatus::Downloading) { + start_next_download(&mut downloads); + } + } + Ok(()) +} + +#[tauri::command] +pub async fn resume_download(category: String, pkgname: String, queue: State<'_, DownloadQueue>) -> Result<(), String> { + let mut downloads = queue.lock().map_err(|e| e.to_string())?; + let task_id = format!("{}/{}", category, pkgname); + + // 先检查任务是否存在且处于暂停状态 + let should_resume = downloads + .get(&task_id) + .map(|task| matches!(task.status, DownloadStatus::Paused)) + .unwrap_or(false); + + if should_resume { + // 检查是否有其他正在下载的任务 + let has_downloading = has_downloading_task(&downloads); + + // 更新任务状态 + if let Some(task) = downloads.get_mut(&task_id) { + task.status = if has_downloading { + DownloadStatus::Queued + } else { + DownloadStatus::Downloading + }; + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_download(category: String, pkgname: String, queue: State<'_, DownloadQueue>) -> Result<(), String> { + let mut downloads = queue.lock().map_err(|e| e.to_string())?; + let task_id = format!("{}/{}", category, pkgname); + + let was_downloading = downloads + .get(&task_id) + .map(|task| matches!(task.status, DownloadStatus::Downloading)) + .unwrap_or(false); + + downloads.remove(&task_id); + + if was_downloading { + start_next_download(&mut downloads); + } + + Ok(()) +} \ No newline at end of file diff --git a/src-tauri/src/handlers/file.rs b/src-tauri/src/handlers/file.rs new file mode 100644 index 0000000..de99391 --- /dev/null +++ b/src-tauri/src/handlers/file.rs @@ -0,0 +1,22 @@ +use std::fs; + +#[tauri::command] +pub fn save_text_file(filename: String, content: String) -> Result<(), String> { + let config_dir = dirs::config_dir().ok_or("无法获取配置目录")?; + let dir = config_dir.join(env!("CARGO_PKG_NAME")); + + // 确保目录存在 + fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {}", e))?; + + let file_path = dir.join(filename); + fs::write(file_path, content).map_err(|e| format!("写入文件失败: {}", e)) +} + +#[tauri::command] +pub fn read_text_file(filename: String) -> Result { + let config_dir = dirs::config_dir().ok_or("无法获取配置目录")?; + let dir = config_dir.join(env!("CARGO_PKG_NAME")); + let file_path = dir.join(filename); + + fs::read_to_string(file_path).map_err(|e| format!("读取文件失败: {}", e)) +} \ No newline at end of file diff --git a/src-tauri/src/handlers/mod.rs b/src-tauri/src/handlers/mod.rs index 5be582a..8287a8e 100644 --- a/src-tauri/src/handlers/mod.rs +++ b/src-tauri/src/handlers/mod.rs @@ -1,4 +1,6 @@ pub mod category; pub mod server; pub mod app; -pub mod home; \ No newline at end of file +pub mod home; +pub mod file; +pub mod download; \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70d466f..dee3c24 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) + .manage(handlers::download::DownloadQueue::default()) .invoke_handler(tauri::generate_handler![ handlers::category::get_all_categories, handlers::category::get_category_apps, @@ -19,6 +20,13 @@ pub fn run() { handlers::home::get_home_links, handlers::home::get_home_lists, handlers::home::get_home_list_apps, + handlers::file::read_text_file, + handlers::file::save_text_file, + handlers::download::get_downloads, + handlers::download::add_download, + handlers::download::pause_download, + handlers::download::resume_download, + handlers::download::cancel_download, utils::get_user_agent, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/models/download.rs b/src-tauri/src/models/download.rs new file mode 100644 index 0000000..9bfbdee --- /dev/null +++ b/src-tauri/src/models/download.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DownloadTask { + pub category: String, + pub pkgname: String, + pub filename: String, + pub status: DownloadStatus, + pub progress: f32, + pub icon: String, + pub name: String, + pub speed: Option, + pub size: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum DownloadStatus { + Downloading, + Queued, + Paused, + Completed, + Error, +} \ No newline at end of file diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 986b78b..89f75a3 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod category; pub mod app; -pub mod home; \ No newline at end of file +pub mod home; +pub mod download; \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d78fcf4..93bf87a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,6 +30,12 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "linux": { + "deb": { + "files": {}, + "depends": ["aria2"] + } + } } } diff --git a/src/components/DownloadCard/index.tsx b/src/components/DownloadCard/index.tsx new file mode 100644 index 0000000..9c3d749 --- /dev/null +++ b/src/components/DownloadCard/index.tsx @@ -0,0 +1,61 @@ +import { Component } from 'solid-js'; +import { Progress } from '@/components/ui/progress'; +import { Button } from '@/components/ui/button'; +import { Pause, Play, X } from 'lucide-solid'; +import { DownloadTask } from '@/types/download'; +import { useDownloadsStore } from '@/features/downloads/store'; + +const DownloadCard: Component<{ download: DownloadTask }> = (props) => { + const { pauseDownload, resumeDownload, cancelDownload } = useDownloadsStore(); + + return ( +
+ {props.download.name} +
+
+

{props.download.name}

+
+ {props.download.status === 'queued' ? '排队中' : + props.download.status === 'downloading' && props.download.speed ? + `${props.download.speed} - ` : ''} + {props.download.status !== 'queued' && props.download.size} +
+
+
+ + {props.download.progress}% + {(props.download.status === 'downloading' || props.download.status === 'queued') && ( + + )} + {props.download.status === 'paused' && ( + + )} + +
+
+
+ ); +}; + +export default DownloadCard; \ No newline at end of file diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index b25380b..2d62576 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -24,12 +24,12 @@ const menuItems = [ }, { title: '我的收藏', - url: '/', + url: '/collections', icon: () => }, { title: '下载列表', - url: '/', + url: '/downloads', icon: () => } ]; diff --git a/src/components/TitleBar/store.ts b/src/components/TitleBar/store.ts index 6880928..0af65ad 100644 --- a/src/components/TitleBar/store.ts +++ b/src/components/TitleBar/store.ts @@ -5,8 +5,11 @@ export const useTitleBarStore = () => { const location = useLocation(); let lastScrollPosition = 0; + // 定义可返回的路由前缀列表 + const BACK_ROUTE_PREFIXES = ['/app', '/search', '/collectionDetail'] as const; + const canGoBack = () => { - return location.pathname.startsWith('/app'); + return BACK_ROUTE_PREFIXES.some(prefix => location.pathname.startsWith(prefix)); }; const saveScrollPosition = () => { diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..3f4b7ba --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,63 @@ +import type { ValidComponent } from "solid-js" +import { Match, splitProps, Switch } from "solid-js" + +import * as CheckboxPrimitive from "@kobalte/core/checkbox" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" + +import { cn } from "@/lib/utils" + +type CheckboxRootProps = + CheckboxPrimitive.CheckboxRootProps & { + class?: string | undefined + onCheckedChange?: (checked: boolean) => void + } + +const Checkbox = ( + props: PolymorphicProps> +) => { + const [local, others] = splitProps(props as CheckboxRootProps, ["class"]) + return ( + + + + + + + + + + + + + + + + + + + + ) +} + +export { Checkbox } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..de0bb42 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +import * as DialogPrimitive from "@kobalte/core/dialog" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal: Component = (props) => { + const [, rest] = splitProps(props, ["children"]) + return ( + +
+ {props.children} +
+
+ ) +} + +type DialogOverlayProps = + DialogPrimitive.DialogOverlayProps & { class?: string | undefined } + +const DialogOverlay = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogOverlayProps, ["class"]) + return ( + + ) +} + +type DialogContentProps = + DialogPrimitive.DialogContentProps & { + class?: string | undefined + children?: JSX.Element + } + +const DialogContent = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogContentProps, ["class", "children"]) + return ( + + + + {props.children} + + + + + + Close + + + + ) +} + +const DialogHeader: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return ( +
+ ) +} + +const DialogFooter: Component> = (props) => { + const [, rest] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type DialogTitleProps = DialogPrimitive.DialogTitleProps & { + class?: string | undefined +} + +const DialogTitle = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogTitleProps, ["class"]) + return ( + + ) +} + +type DialogDescriptionProps = + DialogPrimitive.DialogDescriptionProps & { + class?: string | undefined + } + +const DialogDescription = ( + props: PolymorphicProps> +) => { + const [, rest] = splitProps(props as DialogDescriptionProps, ["class"]) + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..f8e1fbd --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import { Component, JSX, splitProps } from "solid-js"; +import { cn } from "@/lib/utils"; + +export interface InputProps extends JSX.InputHTMLAttributes {} + +const Input: Component = (props) => { + const [, inputProps] = splitProps(props, ["class"]); + + return ( + + ); +}; + +export { Input }; \ No newline at end of file diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..afba022 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import type { Component, ComponentProps } from "solid-js" +import { splitProps } from "solid-js" + +import { cn } from "@/lib/utils" + +const Label: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]) + return ( +