✨ 添加收藏和下载队列
This commit is contained in:
43
src-tauri/Cargo.lock
generated
43
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
153
src-tauri/src/handlers/download.rs
Normal file
153
src-tauri/src/handlers/download.rs
Normal 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(())
|
||||
}
|
||||
22
src-tauri/src/handlers/file.rs
Normal file
22
src-tauri/src/handlers/file.rs
Normal 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))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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!())
|
||||
|
||||
24
src-tauri/src/models/download.rs
Normal file
24
src-tauri/src/models/download.rs
Normal 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,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod category;
|
||||
pub mod app;
|
||||
pub mod home;
|
||||
pub mod home;
|
||||
pub mod download;
|
||||
@@ -30,6 +30,12 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"files": {},
|
||||
"depends": ["aria2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
61
src/components/DownloadCard/index.tsx
Normal file
61
src/components/DownloadCard/index.tsx
Normal 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;
|
||||
@@ -24,12 +24,12 @@ const menuItems = [
|
||||
},
|
||||
{
|
||||
title: '我的收藏',
|
||||
url: '/',
|
||||
url: '/collections',
|
||||
icon: () => <Heart size={20} />
|
||||
},
|
||||
{
|
||||
title: '下载列表',
|
||||
url: '/',
|
||||
url: '/downloads',
|
||||
icon: () => <Download size={20} />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
63
src/components/ui/checkbox.tsx
Normal file
63
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal 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
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal 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 };
|
||||
19
src/components/ui/label.tsx
Normal file
19
src/components/ui/label.tsx
Normal 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 }
|
||||
34
src/components/ui/progress.tsx
Normal file
34
src/components/ui/progress.tsx
Normal 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 }
|
||||
87
src/components/ui/tabs.tsx
Normal file
87
src/components/ui/tabs.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
51
src/features/collection/CollectionDetail.tsx
Normal file
51
src/features/collection/CollectionDetail.tsx
Normal 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;
|
||||
37
src/features/collection/Collections.tsx
Normal file
37
src/features/collection/Collections.tsx
Normal 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;
|
||||
95
src/features/collection/store.ts
Normal file
95
src/features/collection/store.ts
Normal 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();
|
||||
35
src/features/downloads/Downloads.tsx
Normal file
35
src/features/downloads/Downloads.tsx
Normal 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;
|
||||
75
src/features/downloads/store.ts
Normal file
75
src/features/downloads/store.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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
68
src/lib/api/download.ts
Normal 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
27
src/lib/api/file.ts
Normal 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
22
src/types/collection.ts
Normal 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
21
src/types/download.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user