添加收藏和下载队列

This commit is contained in:
柚子
2025-02-06 16:43:09 +08:00
parent b2f458f3b8
commit 56fa6a8a2d
29 changed files with 1284 additions and 19 deletions

43
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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<HashMap<String, DownloadTask>>;
#[tauri::command]
pub async fn get_downloads(queue: State<'_, DownloadQueue>) -> Result<Vec<DownloadTask>, String> {
let downloads = queue.lock().map_err(|e| e.to_string())?;
Ok(downloads.values().cloned().collect())
}
// 检查是否有正在下载的任务
fn has_downloading_task(downloads: &HashMap<String, DownloadTask>) -> bool {
downloads.values().any(|task| matches!(task.status, DownloadStatus::Downloading))
}
// 获取下一个等待下载的任务
fn get_next_queued_task(downloads: &mut HashMap<String, DownloadTask>) -> Option<String> {
downloads
.iter()
.find(|(_, task)| matches!(task.status, DownloadStatus::Queued))
.map(|(task_id, _)| task_id.clone())
}
// 开始下载下一个任务
fn start_next_download(downloads: &mut HashMap<String, DownloadTask>) {
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(())
}

View File

@@ -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<String, String> {
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))
}

View File

@@ -1,4 +1,6 @@
pub mod category;
pub mod server;
pub mod app;
pub mod home;
pub mod home;
pub mod file;
pub mod download;

View File

@@ -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!())

View File

@@ -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<String>,
pub size: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum DownloadStatus {
Downloading,
Queued,
Paused,
Completed,
Error,
}

View File

@@ -1,3 +1,4 @@
pub mod category;
pub mod app;
pub mod home;
pub mod home;
pub mod download;

View File

@@ -30,6 +30,12 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"linux": {
"deb": {
"files": {},
"depends": ["aria2"]
}
}
}
}

View File

@@ -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 (
<div class="flex items-center gap-3 p-4 bg-card rounded-lg border border-border/40">
<img src={props.download.icon} alt={props.download.name} class="w-12 h-12 rounded-lg" />
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<h3 class="font-medium">{props.download.name}</h3>
<div class="text-sm text-muted-foreground">
{props.download.status === 'queued' ? '排队中' :
props.download.status === 'downloading' && props.download.speed ?
`${props.download.speed} - ` : ''}
{props.download.status !== 'queued' && props.download.size}
</div>
</div>
<div class="flex items-center gap-2">
<Progress value={props.download.progress} class="flex-1" />
<span class="text-sm text-muted-foreground">{props.download.progress}%</span>
{(props.download.status === 'downloading' || props.download.status === 'queued') && (
<Button
size="icon"
variant="ghost"
class='h-8 w-8 px-2'
onClick={() => pauseDownload(props.download.category, props.download.pkgname)}
>
<Pause size={16} />
</Button>
)}
{props.download.status === 'paused' && (
<Button
size="icon"
variant="ghost"
class='h-8 w-8 px-2'
onClick={() => resumeDownload(props.download.category, props.download.pkgname)}
>
<Play size={16} />
</Button>
)}
<Button
size="icon"
variant="ghost"
class="text-destructive h-8 w-8 px-2"
onClick={() => cancelDownload(props.download.category, props.download.pkgname)}
>
<X size={16} />
</Button>
</div>
</div>
</div>
);
};
export default DownloadCard;

View File

@@ -24,12 +24,12 @@ const menuItems = [
},
{
title: '我的收藏',
url: '/',
url: '/collections',
icon: () => <Heart size={20} />
},
{
title: '下载列表',
url: '/',
url: '/downloads',
icon: () => <Download size={20} />
}
];

View File

@@ -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 = () => {

View File

@@ -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<T extends ValidComponent = "div"> =
CheckboxPrimitive.CheckboxRootProps<T> & {
class?: string | undefined
onCheckedChange?: (checked: boolean) => void
}
const Checkbox = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, CheckboxRootProps<T>>
) => {
const [local, others] = splitProps(props as CheckboxRootProps, ["class"])
return (
<CheckboxPrimitive.Root
class={cn("items-top group relative flex space-x-2", local.class)}
{...others}
>
<CheckboxPrimitive.Input class="peer" />
<CheckboxPrimitive.Control class="size-4 shrink-0 rounded-sm border border-primary ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 data-[checked]:border-none data-[indeterminate]:border-none data-[checked]:bg-primary data-[indeterminate]:bg-primary data-[checked]:text-primary-foreground data-[indeterminate]:text-primary-foreground">
<CheckboxPrimitive.Indicator>
<Switch>
<Match when={!others.indeterminate}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M5 12l5 5l10 -10" />
</svg>
</Match>
<Match when={others.indeterminate}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M5 12l14 0" />
</svg>
</Match>
</Switch>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Control>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -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<DialogPrimitive.DialogPortalProps> = (props) => {
const [, rest] = splitProps(props, ["children"])
return (
<DialogPrimitive.Portal {...rest}>
<div class="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{props.children}
</div>
</DialogPrimitive.Portal>
)
}
type DialogOverlayProps<T extends ValidComponent = "div"> =
DialogPrimitive.DialogOverlayProps<T> & { class?: string | undefined }
const DialogOverlay = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DialogOverlayProps<T>>
) => {
const [, rest] = splitProps(props as DialogOverlayProps, ["class"])
return (
<DialogPrimitive.Overlay
class={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0",
props.class
)}
{...rest}
/>
)
}
type DialogContentProps<T extends ValidComponent = "div"> =
DialogPrimitive.DialogContentProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const DialogContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DialogContentProps<T>>
) => {
const [, rest] = splitProps(props as DialogContentProps, ["class", "children"])
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
class={cn(
"fixed left-1/2 top-1/2 z-50 grid max-h-screen w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto border bg-background p-6 shadow-lg duration-200 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[expanded]:slide-in-from-left-1/2 data-[expanded]:slide-in-from-top-[48%] sm:rounded-lg",
props.class
)}
{...rest}
>
{props.children}
<DialogPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[expanded]:bg-accent data-[expanded]:text-muted-foreground">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
<span class="sr-only">Close</span>
</DialogPrimitive.CloseButton>
</DialogPrimitive.Content>
</DialogPortal>
)
}
const DialogHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return (
<div class={cn("flex flex-col space-y-1.5 text-center sm:text-left", props.class)} {...rest} />
)
}
const DialogFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return (
<div
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", props.class)}
{...rest}
/>
)
}
type DialogTitleProps<T extends ValidComponent = "h2"> = DialogPrimitive.DialogTitleProps<T> & {
class?: string | undefined
}
const DialogTitle = <T extends ValidComponent = "h2">(
props: PolymorphicProps<T, DialogTitleProps<T>>
) => {
const [, rest] = splitProps(props as DialogTitleProps, ["class"])
return (
<DialogPrimitive.Title
class={cn("text-lg font-semibold leading-none tracking-tight", props.class)}
{...rest}
/>
)
}
type DialogDescriptionProps<T extends ValidComponent = "p"> =
DialogPrimitive.DialogDescriptionProps<T> & {
class?: string | undefined
}
const DialogDescription = <T extends ValidComponent = "p">(
props: PolymorphicProps<T, DialogDescriptionProps<T>>
) => {
const [, rest] = splitProps(props as DialogDescriptionProps, ["class"])
return (
<DialogPrimitive.Description
class={cn("text-sm text-muted-foreground", props.class)}
{...rest}
/>
)
}
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View File

@@ -0,0 +1,20 @@
import { Component, JSX, splitProps } from "solid-js";
import { cn } from "@/lib/utils";
export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {}
const Input: Component<InputProps> = (props) => {
const [, inputProps] = splitProps(props, ["class"]);
return (
<input
class={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
props.class
)}
{...inputProps}
/>
);
};
export { Input };

View File

@@ -0,0 +1,19 @@
import type { Component, ComponentProps } from "solid-js"
import { splitProps } from "solid-js"
import { cn } from "@/lib/utils"
const Label: Component<ComponentProps<"label">> = (props) => {
const [local, others] = splitProps(props, ["class"])
return (
<label
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
local.class
)}
{...others}
/>
)
}
export { Label }

View File

@@ -0,0 +1,34 @@
import type { Component, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
import * as ProgressPrimitive from "@kobalte/core/progress"
import { Label } from "@/components/ui/label"
type ProgressRootProps<T extends ValidComponent = "div"> =
ProgressPrimitive.ProgressRootProps<T> & { children?: JSX.Element }
const Progress = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, ProgressRootProps<T>>
) => {
const [local, others] = splitProps(props as ProgressRootProps, ["children"])
return (
<ProgressPrimitive.Root {...others}>
{local.children}
<ProgressPrimitive.Track class="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<ProgressPrimitive.Fill class="h-full w-[var(--kb-progress-fill-width)] flex-1 bg-primary transition-all" />
</ProgressPrimitive.Track>
</ProgressPrimitive.Root>
)
}
const ProgressLabel: Component<ProgressPrimitive.ProgressLabelProps> = (props) => {
return <ProgressPrimitive.Label as={Label} {...props} />
}
const ProgressValueLabel: Component<ProgressPrimitive.ProgressValueLabelProps> = (props) => {
return <ProgressPrimitive.ValueLabel as={Label} {...props} />
}
export { Progress, ProgressLabel, ProgressValueLabel }

View File

@@ -0,0 +1,87 @@
import type { ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
import * as TabsPrimitive from "@kobalte/core/tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
type TabsListProps<T extends ValidComponent = "div"> = TabsPrimitive.TabsListProps<T> & {
class?: string | undefined
}
const TabsList = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, TabsListProps<T>>
) => {
const [local, others] = splitProps(props as TabsListProps, ["class"])
return (
<TabsPrimitive.List
class={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
local.class
)}
{...others}
/>
)
}
type TabsTriggerProps<T extends ValidComponent = "button"> = TabsPrimitive.TabsTriggerProps<T> & {
class?: string | undefined
}
const TabsTrigger = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, TabsTriggerProps<T>>
) => {
const [local, others] = splitProps(props as TabsTriggerProps, ["class"])
return (
<TabsPrimitive.Trigger
class={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[selected]:bg-background data-[selected]:text-foreground data-[selected]:shadow-sm",
local.class
)}
{...others}
/>
)
}
type TabsContentProps<T extends ValidComponent = "div"> = TabsPrimitive.TabsContentProps<T> & {
class?: string | undefined
}
const TabsContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, TabsContentProps<T>>
) => {
const [local, others] = splitProps(props as TabsContentProps, ["class"])
return (
<TabsPrimitive.Content
class={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
local.class
)}
{...others}
/>
)
}
type TabsIndicatorProps<T extends ValidComponent = "div"> = TabsPrimitive.TabsIndicatorProps<T> & {
class?: string | undefined
}
const TabsIndicator = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, TabsIndicatorProps<T>>
) => {
const [local, others] = splitProps(props as TabsIndicatorProps, ["class"])
return (
<TabsPrimitive.Indicator
class={cn(
"duration-250ms absolute transition-all data-[orientation=horizontal]:-bottom-px data-[orientation=vertical]:-right-px data-[orientation=horizontal]:h-[2px] data-[orientation=vertical]:w-[2px]",
local.class
)}
{...others}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, TabsIndicator }

View File

@@ -1,17 +1,49 @@
import { Component } from 'solid-js';
import { Component, createSignal } from 'solid-js';
import { useParams } from '@solidjs/router';
import { useAppDetailStore } from './store';
import './AppDetail.css';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import ScreenshotCarousel from '@/components/ScreenshotCarousel';
import { generateShareLinks, copy } from '@/lib/share';
import { showToast } from "@/components/ui/toast"
import { showToast } from "@/components/ui/toast";
import { useCollectionStore } from '@/features/collection/store';
import { useDownloadsStore } from '@/features/downloads/store';
import { Progress } from '@/components/ui/progress';
import { X } from 'lucide-solid';
const AppDetail: Component = () => {
const params = useParams();
const { app, loading } = useAppDetailStore(params.category, params.pkgname);
const { collections, addCollection, addAppToCollections, removeAppFromCollection, isAppInCollection } = useCollectionStore();
const { downloads, addDownload, cancelDownload } = useDownloadsStore();
const [newCollectionName, setNewCollectionName] = createSignal('');
const [newCollectionDesc, setNewCollectionDesc] = createSignal('');
const [showNewCollectionDialog, setShowNewCollectionDialog] = createSignal(false);
const [showCollectionDialog, setShowCollectionDialog] = createSignal(false);
const [selectedCollections, setSelectedCollections] = createSignal<string[]>([]);
const handleCreateCollection = () => {
if (!newCollectionName()) {
showToast({ description: '请输入收藏单名称', variant: "warning" });
return;
}
const newCollection = addCollection({
name: newCollectionName(),
description: newCollectionDesc(),
});
// 直接将当前应用添加到新创建的收藏单中
addAppToCollections([newCollection.id], params.category, params.pkgname);
setNewCollectionName('');
setNewCollectionDesc('');
setShowNewCollectionDialog(false);
showToast({ description: '收藏单创建成功', variant: "success" });
};
return (
<div class="w-full h-full">
@@ -26,9 +58,10 @@ const AppDetail: Component = () => {
<div class="py-2" />
<Skeleton height={32} width={200} />
</div>
<div class="flex flex-col gap-2">
<Skeleton height={40} width={96} radius={6} />
<Skeleton height={40} width={96} radius={6} />
<div class="flex flex-col gap-2 w-[120px]">
<Skeleton height={40} width={120} radius={6} />
<Skeleton height={40} width={120} radius={6} />
<Skeleton height={40} width={120} radius={6} />
</div>
</div>
<div class="mt-2">
@@ -76,11 +109,96 @@ const AppDetail: Component = () => {
<div class="py-2" />
<h2 class="app-name text-2xl font-bold pt-2">{app()?.Name}</h2>
</div>
<div class="flex flex-col gap-2">
<Button size="lg"></Button>
<div class="flex flex-col gap-2 w-[120px]">
{downloads().some(task => task.category === params.category && task.pkgname === params.pkgname) ? (
<div class="flex flex-col gap-2 w-full pb-2">
{downloads().map(download => {
if (download.category === params.category && download.pkgname === params.pkgname) {
return (
<div class="flex items-center gap-2 group relative">
<Progress value={download.progress} class="flex-1" />
<div class="flex items-center gap-2 group-hover:opacity-0 transition-opacity">
<span class="text-sm text-muted-foreground">{download.progress}%</span>
{download.speed && (
<span class="text-sm text-muted-foreground">{download.speed}</span>
)}
</div>
<button
class="absolute right-1 bg-red-500 rounded-full w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white hover:bg-red-600"
onClick={() => {
cancelDownload(params.category, params.pkgname);
}}
>
<X />
</button>
</div>
);
}
return null;
})}
</div>
) : (
<Button
size="lg"
class='w-full'
onClick={() => {
const currentApp = app();
if (!currentApp) {
showToast({ description: '获取应用信息失败', variant: "error" });
return;
}
if (!currentApp.Filename) {
showToast({ description: '获取应用下载信息失败', variant: "error" });
return;
}
addDownload(params.category, params.pkgname, currentApp.Filename, currentApp.Name || '');
showToast({ description: '已添加到下载队列', variant: "success" });
}}
>
</Button>
)}
<Dialog open={showCollectionDialog()} onOpenChange={setShowCollectionDialog}>
<DialogTrigger>
<Button variant="outline" size="lg" class='w-full'></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4">
{collections().map(collection => (
<div class="flex items-center space-x-2">
<Checkbox
id={`collection-${collection.id}`}
checked={isAppInCollection(collection.id, params.category, params.pkgname)}
onChange={(checked) => {
if (checked) {
addAppToCollections([collection.id], params.category, params.pkgname);
} else {
removeAppFromCollection(collection.id, params.category, params.pkgname);
}
}}
/>
<Label for={`collection-${collection.id}-input`}>{collection.name}</Label>
</div>
))}
<Button variant="outline" onClick={() => setShowNewCollectionDialog(true)}></Button>
<Button onClick={() => {
const selected = selectedCollections();
addAppToCollections(selected, params.category, params.pkgname);
showToast({ description: '添加到收藏单成功', variant: "success" });
setSelectedCollections([]);
setShowCollectionDialog(false);
}}></Button>
</div>
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="lg"></Button>
<Button variant="outline" size="lg" class='w-full'></Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel>
@@ -145,6 +263,32 @@ const AppDetail: Component = () => {
</div>
</div>
)}
<Dialog open={showNewCollectionDialog()} onOpenChange={setShowNewCollectionDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label></Label>
<Input
value={newCollectionName()}
onInput={(e) => setNewCollectionName(e.currentTarget.value)}
placeholder="请输入收藏单名称"
/>
</div>
<div class="flex flex-col gap-2">
<Label></Label>
<Input
value={newCollectionDesc()}
onInput={(e) => setNewCollectionDesc(e.currentTarget.value)}
placeholder="请输入收藏单描述(选填)"
/>
</div>
<Button onClick={handleCreateCollection}></Button>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { Component, createEffect, createSignal } from 'solid-js';
import { useParams } from '@solidjs/router';
import { useCollectionStore } from './store';
import AppList from '@/components/AppList';
import { AppItem } from '@/types/app';
import { fetchCategoryApps } from '@/features/categories/store';
const CollectionDetail: Component = () => {
const params = useParams();
const { collections } = useCollectionStore();
const [apps, setApps] = createSignal<AppItem[]>([]);
const [loading, setLoading] = createSignal(true);
createEffect(async () => {
if (params.id) {
setLoading(true);
const collection = collections().find(c => c.id === params.id);
if (collection) {
try {
const appPromises = collection.apps.map(async (app) => {
const categoryApps = await fetchCategoryApps(app.Category);
return categoryApps.find(categoryApp => categoryApp.pkgname === app.Pkgname);
});
const appResults = await Promise.all(appPromises);
setApps(appResults.filter((app): app is AppItem => app !== undefined));
} catch (error) {
console.error('Failed to load collection apps:', error);
} finally {
setLoading(false);
}
}
}
});
return (
<div class="p-6 w-full h-full">
<h1 class="text-2xl font-bold">
{collections()?.find(c => c.id === params.id)?.name}
</h1>
<p class="text-gray-500 mb-6">
{collections()?.find(c => c.id === params.id)?.description}
</p>
<AppList
apps={apps()}
loading={loading()}
/>
</div>
);
};
export default CollectionDetail;

View File

@@ -0,0 +1,37 @@
import { Component } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { useCollectionStore } from './store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const Collections: Component = () => {
const navigate = useNavigate();
const { collections } = useCollectionStore();
return (
<div class="p-6 w-full h-full">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{collections().map((collection) => (
<Card
class="cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate(`/collectionDetail/${collection.id}`)}
>
<CardHeader class='p-4'>
<CardTitle>{collection.name}</CardTitle>
<CardDescription>{collection.description}</CardDescription>
</CardHeader>
<CardContent class='px-4'>
<p class="text-sm text-muted-foreground">
{collection.apps.length}
</p>
<p class="text-sm text-muted-foreground">
{new Date(collection.updatedAt).toLocaleDateString()}
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
};
export default Collections;

View File

@@ -0,0 +1,95 @@
import { createSignal, createEffect } from 'solid-js';
import { Collection, CreateCollectionParams } from '@/types/collection';
import { saveTextFile, readTextFile } from '@/lib/api/file';
const COLLECTIONS_FILE = 'collections.json';
const [collections, setCollections] = createSignal<Collection[]>([]);
const [initialized, setInitialized] = createSignal(false);
async function initCollections() {
try {
const data = await readTextFile(COLLECTIONS_FILE);
const parsedData = JSON.parse(data);
if (Array.isArray(parsedData)) {
setCollections(parsedData);
}
} catch (error) {
console.error('Failed to load collections:', error);
// 如果文件不存在,使用空数组,但不会覆盖已有文件
} finally {
setInitialized(true);
}
}
createEffect(() => {
// 只有在初始化完成后才开始自动保存
if (initialized()) {
const currentCollections = collections();
saveTextFile(COLLECTIONS_FILE, JSON.stringify(currentCollections))
.catch(error => console.error('Failed to save collections:', error));
}
});
export const useCollectionStore = () => {
const addCollection = (params: CreateCollectionParams): Collection => {
const newCollection: Collection = {
id: Date.now().toString(),
name: params.name,
description: params.description,
apps: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
setCollections([...collections(), newCollection]);
return newCollection;
};
const addAppToCollections = (collectionIds: string[], Category: string, Pkgname: string) => {
setCollections(prev => prev.map(collection => {
if (collectionIds.includes(collection.id)) {
const appExists = collection.apps.some(app =>
app.Category === Category && app.Pkgname === Pkgname
);
if (!appExists) {
return {
...collection,
apps: [...collection.apps, { Category, Pkgname }],
updatedAt: Date.now(),
};
}
}
return collection;
}));
};
const removeAppFromCollection = (collectionId: string, Category: string, Pkgname: string) => {
setCollections(prev => prev.map(collection => {
if (collection.id === collectionId) {
return {
...collection,
apps: collection.apps.filter(app =>
!(app.Category === Category && app.Pkgname === Pkgname)
),
updatedAt: Date.now(),
};
}
return collection;
}));
};
const isAppInCollection = (collectionId: string, Category: string, Pkgname: string): boolean => {
const collection = collections().find(c => c.id === collectionId);
return collection?.apps.some(app => app.Category === Category && app.Pkgname === Pkgname) || false;
};
return {
collections,
addCollection,
addAppToCollections,
removeAppFromCollection,
isAppInCollection,
};
};
// 初始化收藏单数据
initCollections();

View File

@@ -0,0 +1,35 @@
import { Component, For } from 'solid-js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import DownloadCard from '../../components/DownloadCard';
import { useDownloadsStore } from './store';
const Downloads: Component = () => {
const { activeDownloads, completedDownloads } = useDownloadsStore();
return (
<div class="p-6 w-full h-full">
<Tabs defaultValue="active" class="w-full">
<TabsList>
<TabsTrigger value="active"> ({activeDownloads().length})</TabsTrigger>
<TabsTrigger value="completed"> ({completedDownloads().length})</TabsTrigger>
</TabsList>
<TabsContent value="active" class="mt-6">
<div class="flex flex-col gap-4">
<For each={activeDownloads()}>
{(download) => <DownloadCard download={download} />}
</For>
</div>
</TabsContent>
<TabsContent value="completed" class="mt-6">
<div class="flex flex-col gap-4">
<For each={completedDownloads()}>
{(download) => <DownloadCard download={download} />}
</For>
</div>
</TabsContent>
</Tabs>
</div>
);
};
export default Downloads;

View File

@@ -0,0 +1,75 @@
import { createSignal, createEffect } from 'solid-js';
import { DownloadTask } from '@/types/download';
import { getDownloads, addDownload as addDownloadApi, pauseDownload as pauseDownloadApi, resumeDownload as resumeDownloadApi, cancelDownload as cancelDownloadApi } from '@/lib/api/download';
const [downloads, setDownloads] = createSignal<DownloadTask[]>([]);
// 初始化时获取下载列表
createEffect(async () => {
try {
const downloadList = await getDownloads();
setDownloads(downloadList);
} catch (error) {
console.error('获取下载列表失败:', error);
}
});
export const useDownloadsStore = () => {
const activeDownloads = () => downloads().filter(item =>
['downloading', 'paused', 'queued'].includes(item.status)
);
const completedDownloads = () => downloads().filter(item =>
item.status === 'completed'
);
const addDownload = async (category: string, pkgname: string, filename: string, name: string) => {
try {
await addDownloadApi(category, pkgname, filename, name);
const updatedList = await getDownloads();
setDownloads(updatedList);
} catch (error) {
console.error('添加下载任务失败:', error);
}
};
const pauseDownload = async (category: string, pkgname: string) => {
try {
await pauseDownloadApi(category, pkgname);
const updatedList = await getDownloads();
setDownloads(updatedList);
} catch (error) {
console.error('暂停下载任务失败:', error);
}
};
const resumeDownload = async (category: string, pkgname: string) => {
try {
await resumeDownloadApi(category, pkgname);
const updatedList = await getDownloads();
setDownloads(updatedList);
} catch (error) {
console.error('继续下载任务失败:', error);
}
};
const cancelDownload = async (category: string, pkgname: string) => {
try {
await cancelDownloadApi(category, pkgname);
const updatedList = await getDownloads();
setDownloads(updatedList);
} catch (error) {
console.error('取消下载任务失败:', error);
}
};
return {
downloads,
activeDownloads,
completedDownloads,
addDownload,
pauseDownload,
resumeDownload,
cancelDownload
};
};

View File

@@ -13,6 +13,18 @@ const routes = {
path: "/",
component: lazy(() => import("./features/home/Home")),
},
{
path: "/collections",
component: lazy(() => import("./features/collection/Collections")),
},
{
path: "/collectionDetail/:id",
component: lazy(() => import("./features/collection/CollectionDetail")),
},
{
path: "/downloads",
component: lazy(() => import("./features/downloads/Downloads")),
},
{
path: "/categories/:id",
component: lazy(() => import("./features/categories/Categories")),

68
src/lib/api/download.ts Normal file
View File

@@ -0,0 +1,68 @@
import { invoke } from "@tauri-apps/api/core";
import { DownloadTask } from "@/types/download";
/**
* 获取下载列表
* @returns Promise<DownloadTask[]>
*/
export async function getDownloads(): Promise<DownloadTask[]> {
try {
return await invoke<DownloadTask[]>("get_downloads");
} catch (error) {
throw new Error(`获取下载列表失败: ${error}`);
}
}
/**
* 添加下载任务
* @param category 应用分类
* @param pkgname 应用包名
* @param filename 文件名
* @param name 应用名称
*/
export async function addDownload(category: string, pkgname: string, filename: string, name: string): Promise<void> {
try {
await invoke("add_download", { category, pkgname, filename, name });
} catch (error) {
throw new Error(`添加下载任务失败: ${error}`);
}
}
/**
* 暂停下载任务
* @param category 应用分类
* @param pkgname 应用包名
*/
export async function pauseDownload(category: string, pkgname: string): Promise<void> {
try {
await invoke("pause_download", { category, pkgname });
} catch (error) {
throw new Error(`暂停下载任务失败: ${error}`);
}
}
/**
* 继续下载任务
* @param category 应用分类
* @param pkgname 应用包名
*/
export async function resumeDownload(category: string, pkgname: string): Promise<void> {
try {
await invoke("resume_download", { category, pkgname });
} catch (error) {
throw new Error(`继续下载任务失败: ${error}`);
}
}
/**
* 取消下载任务
* @param category 应用分类
* @param pkgname 应用包名
*/
export async function cancelDownload(category: string, pkgname: string): Promise<void> {
try {
await invoke("cancel_download", { category, pkgname });
} catch (error) {
throw new Error(`取消下载任务失败: ${error}`);
}
}

27
src/lib/api/file.ts Normal file
View File

@@ -0,0 +1,27 @@
import { invoke } from "@tauri-apps/api/core";
/**
* 保存文本文件到配置目录
* @param filename 文件名
* @param content 文件内容
*/
export async function saveTextFile(filename: string, content: string): Promise<void> {
try {
await invoke('save_text_file', { filename, content });
} catch (error) {
throw new Error(`保存文件失败: ${error}`);
}
}
/**
* 从配置目录读取文本文件
* @param filename 文件名
* @returns 文件内容
*/
export async function readTextFile(filename: string): Promise<string> {
try {
return await invoke('read_text_file', { filename });
} catch (error) {
throw new Error(`读取文件失败: ${error}`);
}
}

22
src/types/collection.ts Normal file
View File

@@ -0,0 +1,22 @@
import { AppDetail } from "./app";
export interface Collection {
id: string;
name: string;
description?: string;
apps: {
Category: string;
Pkgname: string;
}[];
createdAt: number;
updatedAt: number;
}
export interface CollectionWithApps extends Collection {
apps: AppDetail[];
}
export interface CreateCollectionParams {
name: string;
description?: string;
}

21
src/types/download.ts Normal file
View File

@@ -0,0 +1,21 @@
// 下载状态枚举
export enum DownloadStatus {
downloading = 'downloading',
queued = 'queued',
paused = 'paused',
completed = 'completed',
error = 'error'
}
// 下载任务接口
export interface DownloadTask {
category: string;
pkgname: string;
filename: string;
status: DownloadStatus;
progress: number;
speed?: string;
size?: string;
icon: string;
name: string;
}