下载模块 Init

This commit is contained in:
zhongluofeng
2026-07-20 18:28:34 +08:00
parent e84958e0fc
commit 548b022426
37 changed files with 4795 additions and 65 deletions
+38
View File
@@ -4,6 +4,9 @@ import { Search, Minus, Square, X, Settings, ChevronRight } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useSearchStore, type SearchItem } from '@/stores/searchStore'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
const tabsStore = useModuleTabsStore()
const props = defineProps<{
modules: Array<{ id: string; name: string; icon: string }>
@@ -119,6 +122,30 @@ const handleBlur = () => {
data-tauri-drag-region
>
<span class="font-semibold text-sm">Thing</span>
<!-- 浮动标签切换器模块内 TabsList 滚出可视区时显示 -->
<Transition name="floating-tabs">
<div
v-if="tabsStore.floatingVisible && tabsStore.tabs.length > 0"
class="flex items-center gap-0.5 ml-2 pointer-events-auto"
>
<button
v-for="tab in tabsStore.tabs"
:key="tab.value"
class="px-2.5 py-1 text-xs font-medium rounded-md transition-colors"
:class="
tabsStore.activeTab === tab.value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/60'
"
@click="tabsStore.setActiveTab(tab.value)"
@mousedown.stop
>
{{ tab.label }}
</button>
</div>
</Transition>
<div class="flex-1"></div>
<div class="relative max-w-xs mr-3 pointer-events-auto">
<Search
@@ -211,4 +238,15 @@ const handleBlur = () => {
.hover-suppressed button:hover {
background-color: transparent !important;
}
/* 浮动标签切换器进出动画:淡入 + 从左侧滑入 */
.floating-tabs-enter-active,
.floating-tabs-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.floating-tabs-enter-from,
.floating-tabs-leave-to {
opacity: 0;
transform: translateX(-12px);
}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PaginationRootEmits, PaginationRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PaginationRootProps & {
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<PaginationRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PaginationRoot
v-slot="slotProps"
data-slot="pagination"
v-bind="forwarded"
:class="cn('mx-auto flex w-full justify-center', props.class)"
>
<slot v-bind="slotProps" />
</PaginationRoot>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { PaginationListProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationList } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PaginationListProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<PaginationList
v-slot="slotProps"
data-slot="pagination-content"
v-bind="delegatedProps"
:class="cn('flex flex-row items-center gap-1', props.class)"
>
<slot v-bind="slotProps" />
</PaginationList>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { PaginationEllipsisProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { MoreHorizontal } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationEllipsis } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<PaginationEllipsis
data-slot="pagination-ellipsis"
v-bind="delegatedProps"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
<span class="sr-only">More pages</span>
</slot>
</PaginationEllipsis>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationFirstProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronLeftIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationFirst, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationFirstProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationFirst
data-slot="pagination-first"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">First</span>
</slot>
</PaginationFirst>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { PaginationListItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { reactiveOmit } from "@vueuse/core"
import { PaginationListItem } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationListItemProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
isActive?: boolean
}>(), {
size: "icon",
})
const delegatedProps = reactiveOmit(props, "class", "size", "isActive")
</script>
<template>
<PaginationListItem
data-slot="pagination-item"
v-bind="delegatedProps"
:class="cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
props.class)"
>
<slot />
</PaginationListItem>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationLastProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronRightIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationLast, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationLastProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationLast
data-slot="pagination-last"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Last</span>
<ChevronRightIcon />
</slot>
</PaginationLast>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronRightIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationNextProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationNext
data-slot="pagination-next"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<span class="hidden sm:block">Next</span>
<ChevronRightIcon />
</slot>
</PaginationNext>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { PaginationPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from '@/components/ui/button'
import { ChevronLeftIcon } from "@lucide/vue"
import { reactiveOmit } from "@vueuse/core"
import { PaginationPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<PaginationPrevProps & {
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}>(), {
size: "default",
})
const delegatedProps = reactiveOmit(props, "class", "size")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<PaginationPrev
data-slot="pagination-previous"
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
v-bind="forwarded"
>
<slot>
<ChevronLeftIcon />
<span class="hidden sm:block">Previous</span>
</slot>
</PaginationPrev>
</template>
+8
View File
@@ -0,0 +1,8 @@
export { default as Pagination } from "./Pagination.vue"
export { default as PaginationContent } from "./PaginationContent.vue"
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue"
export { default as PaginationFirst } from "./PaginationFirst.vue"
export { default as PaginationItem } from "./PaginationItem.vue"
export { default as PaginationLast } from "./PaginationLast.vue"
export { default as PaginationNext } from "./PaginationNext.vue"
export { default as PaginationPrevious } from "./PaginationPrevious.vue"
+1 -1
View File
@@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<TabsTrigger
data-slot="tabs-trigger"
:class="cn(
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,background-color,box-shadow] duration-200 ease-out focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
v-bind="forwardedProps"
+93
View File
@@ -0,0 +1,93 @@
import { nextTick, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
/**
* 模块标签栏通用 composable。
*
* 让任何模块的 TabsList 在滚动被 TitleBar 遮挡时,
* 自动在 TitleBar 中显示一组浮动切换按钮。
*
* ## 使用方式
*
* ```ts
* // 模块 <script setup> 顶部
* const activeTab = ref('overview')
* const tabsListRef = useModuleTabs(activeTab, [
* { value: 'overview', label: '概览' },
* { value: 'settings', label: '设置' }
* ])
* ```
*
* ```vue
* <!-- 模板中给 TabsList 包一层带 ref 的 div -->
* <div ref="tabsListRef">
* <TabsList>...</TabsList>
* </div>
* ```
*
* ## 工作原理
* 1. onMounted 时注册标签到 moduleTabsStoreTitleBar 据此渲染浮动切换器
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
* 4. onUnmounted 时清理 observer 并注销标签
*
* ## 约束
* - TitleBar 高度固定为 40px (h-10)composable 内部已用 44px 裁剪(含缓冲)
* - 一个模块同一时间只能注册一组标签(store 是单例)
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
*/
export function useModuleTabs(
activeTab: Ref<string>,
tabs: ModuleTab[]
): Ref<HTMLElement | null> {
const tabsStore = useModuleTabsStore()
const tabsListRef = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
const setupObserver = () => {
const el = tabsListRef.value
if (!el || observer) return
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
tabsStore.setFloatingVisible(!entry.isIntersecting)
}
},
{
// root=null 表示视口;顶部裁剪 44pxTitleBar 高度 40px + 4px 缓冲)
rootMargin: '-44px 0px 0px 0px',
threshold: 0
}
)
observer.observe(el)
}
// 本地 activeTab → store(用户点击模块内 TabsTrigger 时同步)
watch(activeTab, (val) => {
tabsStore.setActiveTab(val)
})
// store activeTab → 本地(用户点击 TitleBar 浮动切换器时同步)
watch(() => tabsStore.activeTab, (val) => {
if (val && val !== activeTab.value) {
activeTab.value = val
}
})
onMounted(async () => {
tabsStore.registerTabs(tabs, activeTab.value)
await nextTick()
setupObserver()
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
tabsStore.unregisterTabs()
})
return tabsListRef
}
File diff suppressed because it is too large Load Diff
+43 -5
View File
@@ -1,11 +1,27 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
import { invoke } from '@tauri-apps/api/core'
const searchItems: SearchIndexItem[] = [
{
title: '下载管理',
description: '管理下载任务',
keywords: ['下载', 'download', '文件', 'file']
title: '下载任务',
description: '查看与管理下载任务',
keywords: ['下载', 'download', '任务', 'task', 'aria2']
},
{
title: '添加下载',
description: '添加 HTTP/HTTPS 直链下载',
keywords: ['添加', '链接', 'url', 'add', '新建']
},
{
title: '下载设置',
description: '配置下载目录、速度限制与 RPC',
keywords: ['设置', 'setting', 'rpc', '速度', '端口', '目录']
},
{
title: '浏览器扩展',
description: '安装 Thing Extension 接管浏览器下载',
keywords: ['扩展', 'extension', '浏览器', 'chrome', 'edge']
}
]
@@ -13,18 +29,40 @@ export const moduleConfig: ModuleConfig = {
id: 'downloader',
name: '下载器',
icon: 'downloader',
description: 'HTTP下载、BT/磁力链接支持',
description: '基于 aria2 的多线程 HTTP 下载管理',
category: 'network',
defaultEnabled: true,
loader: () => import('./DownloaderModule.vue'),
searchItems,
// 进程由 Aria2Manager 通过 ProcessManager 统一管理(id='downloader'),
// executable/args 在运行时由后端确定,此处仅声明 hasProcess 以便禁用时自动停止。
process: {
name: 'aria2c',
executable: '',
args: ['--enable-rpc', '--rpc-listen-port=6800'],
autoStart: false,
restartOnCrash: true,
maxRestarts: 3
},
lifecycle: {
// 启用模块时若用户开启了"自动启动",则随模块启用而运行 aria2
onEnable: async () => {
try {
const s = await invoke<{ autoStart?: boolean }>('downloader_get_settings')
if (s.autoStart) {
await invoke('downloader_start')
}
} catch {
/* 忽略:可能内核未安装 */
}
},
// 禁用模块时停止 aria2 进程(cleanup_on_exit 会在应用退出时调用)
onDisable: async () => {
try {
await invoke('downloader_stop')
} catch {
/* 忽略:可能进程未运行 */
}
}
},
order: 50
}
+29 -31
View File
@@ -10,6 +10,7 @@ import { invoke } from '@tauri-apps/api/core'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { createLogger } from '@/lib/logger'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -68,6 +69,13 @@ const onConfirmOpenChange = (open: boolean) => {
}
const activeTab = ref('overview')
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'proxies', label: '节点' },
{ value: 'profiles', label: '订阅' },
{ value: 'settings', label: '设置' }
])
const starting = ref(false)
const stopping = ref(false)
const restarting = ref(false)
@@ -613,10 +621,10 @@ const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
// 镜像源选择:'__direct' = GitHub 直连(value 不能用空串,reka-ui SelectItem 禁止空 value
// | 'ghproxy.net' 等预设 key | '__custom' = 自定义
const MIRROR_PRESETS = [
{ label: 'GitHub 直连', value: '__direct', hint: '能访问 GitHub,速度最快' },
{ label: 'GitHub 直连', value: '__direct', hint: '能访问 GitHub 时选择,最稳定' },
{ label: 'gh-proxy.com', value: 'https://gh-proxy.com/', hint: '最推荐公益镜像' },
{ label: 'ghproxy.net', value: 'https://ghproxy.net/', hint: '老牌公益镜像' },
{ label: 'gh-proxy.com', value: 'https://gh-proxy.com/', hint: '公益镜像' },
{ label: 'ghfast.top', value: 'https://ghfast.top/', hint: '较新镜像' },
{ label: 'ghfast.top', value: 'https://ghfast.top/', hint: '较新镜像,备用' },
{ label: '自定义', value: '__custom', hint: '手动输入镜像站前缀' },
] as const
@@ -843,12 +851,14 @@ const saveSettingsForm = async () => {
<template>
<div class="h-full p-6">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
<div ref="tabsListRef">
<TabsList class="grid w-full grid-cols-4 max-w-md !bg-transparent !p-0 !shadow-none">
<TabsTrigger value="overview" class="gap-1.5"><Globe class="size-3.5" />概览</TabsTrigger>
<TabsTrigger value="proxies" class="gap-1.5"><Server class="size-3.5" />节点</TabsTrigger>
<TabsTrigger value="profiles" class="gap-1.5"><ListChecks class="size-3.5" />订阅</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
</TabsList>
</div>
<!-- 概览 -->
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
@@ -873,16 +883,18 @@ const saveSettingsForm = async () => {
<CardContent class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">状态</span>
<span v-if="store.kernel?.exists" class="flex items-center gap-1 text-emerald-500">
<Check class="size-3.5" />已安装
</span>
<span v-else class="flex items-center gap-1 text-red-500">
<AlertCircle class="size-3.5" />未安装
</span>
<Badge v-if="store.kernel?.exists" variant="default" class="gap-1 bg-emerald-500 hover:bg-emerald-500">
<Check class="size-3" />已安装
</Badge>
<Badge v-else variant="destructive" class="gap-1">
<AlertCircle class="size-3" />未安装
</Badge>
</div>
<div v-if="store.kernel?.exists" class="flex items-center justify-between">
<span class="text-muted-foreground">当前版本</span>
<span class="font-mono text-xs" :title="store.kernel?.version ?? ''">{{ versionShort }}</span>
<Badge variant="secondary" class="font-mono text-xs" :title="store.kernel?.version ?? ''">
{{ versionShort }}
</Badge>
</div>
<div v-if="kernelUpdateInfo" class="flex items-center justify-between">
<span class="text-muted-foreground">最新版本</span>
@@ -1479,19 +1491,5 @@ const saveSettingsForm = async () => {
</template>
<style scoped>
/* Tab 内容切换动画 */
.tab-animate {
animation: tabFadeSlide 0.2s ease-out;
}
@keyframes tabFadeSlide {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Tab 内容切换动画已移至 src/style.css 全局 .tab-animate 类,所有模块共用 */
</style>
+403
View File
@@ -0,0 +1,403 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { createLogger } from '@/lib/logger'
const logger = createLogger('downloader')
// ===== 与 Rust 端对应的数据结构(camelCase =====
export interface DownloaderSettings {
rpcPort: number
rpcSecret: string
downloadDir: string
maxConcurrent: number
maxConnectionPerServer: number
split: number
continueDownload: boolean
autoStart: boolean
speedLimit: number
kernelMirrors: string[]
}
/** 内核安装进度事件载荷,对应 Rust 端 InstallProgress */
export interface InstallProgress {
/** downloading | extracting | replacing | done | error */
stage: string
percent: number
downloadedBytes: number
totalBytes: number | null
message: string
}
export interface KernelInfo {
path: string
exists: boolean
version: string | null
}
export interface KernelUpdateInfo {
currentVersion: string | null
latestVersion: string
downloadUrl: string
hasUpdate: boolean
}
export interface DownloaderStatus {
running: boolean
pid: number | null
restartCount: number
}
export interface RpcInfo {
rpcUrl: string
rpcSecret: string
extensionPath: string | null
}
/** aria2 任务文件信息 */
export interface Aria2File {
path: string
length: number
completedLength: number
selected: boolean
}
/** aria2 任务状态(tellActive/tellWaiting/tellStopped 返回项) */
export interface Aria2Task {
gid: string
status: 'active' | 'waiting' | 'paused' | 'complete' | 'removed' | 'error' | string
totalLength: string
completedLength: string
downloadSpeed: string
uploadSpeed: string
connections: string
dir: string
files?: Aria2File[]
bittorrent?: { info?: { name?: string } } | null
errorCode?: string
errorMessage?: string
}
/** 全局统计 */
export interface GlobalStat {
downloadSpeed: string
uploadSpeed: string
numActive: string
numWaiting: string
numStopped: string
numStoppedTotal: string
}
export interface Aria2Version {
version: string
enabledFeatures?: string[]
}
export const useDownloaderStore = defineStore('downloader', () => {
const kernel = ref<KernelInfo | null>(null)
const status = ref<DownloaderStatus>({ running: false, pid: null, restartCount: 0 })
const version = ref<string>('')
const settings = ref<DownloaderSettings | null>(null)
const rpcInfo = ref<RpcInfo | null>(null)
const activeTasks = ref<Aria2Task[]>([])
const waitingTasks = ref<Aria2Task[]>([])
const stoppedTasks = ref<Aria2Task[]>([])
const globalStat = ref<GlobalStat | null>(null)
// ===== 任务历史持久化(localStorage =====
// 即使 aria2 未启动,也能展示最近一次的任务快照
const HISTORY_KEY = 'thing.downloader.taskHistory'
const HISTORY_MAX = 200 // 最多保留 200 条历史记录
/** 将当前任务快照保存到 localStorage(合并 active+waiting+stopped,按 gid 去重) */
const persistHistory = () => {
try {
const map = new Map<string, Aria2Task>()
// 先读已有历史,作为基底
const raw = localStorage.getItem(HISTORY_KEY)
if (raw) {
const existing: Aria2Task[] = JSON.parse(raw)
for (const t of existing) map.set(t.gid, t)
}
// 用最新任务覆盖(active/waiting/stopped 都是最新的)
for (const t of activeTasks.value) map.set(t.gid, t)
for (const t of waitingTasks.value) map.set(t.gid, t)
for (const t of stoppedTasks.value) map.set(t.gid, t)
// 限制条数:优先保留 stopped(已完成/错误),其次 waiting,最后 active
const all = Array.from(map.values())
const priority = { complete: 0, error: 0, removed: 1, active: 2, waiting: 2, paused: 2 } as Record<string, number>
all.sort((a, b) => (priority[a.status] ?? 3) - (priority[b.status] ?? 3))
const trimmed = all.slice(0, HISTORY_MAX)
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed))
} catch (e) {
logger.error('保存任务历史失败: ' + e)
}
}
/** 从 localStorage 加载任务历史,填充到 stoppedTasks(作为历史展示) */
const loadHistory = () => {
try {
const raw = localStorage.getItem(HISTORY_KEY)
if (!raw) return
const history: Aria2Task[] = JSON.parse(raw)
if (!Array.isArray(history)) return
// 仅在没有实时任务时填充(避免覆盖实时数据)
if (stoppedTasks.value.length === 0) {
stoppedTasks.value = history
}
} catch (e) {
logger.error('加载任务历史失败: ' + e)
}
}
// ===== 内核安装进度 =====
const installing = ref(false)
const installProgress = ref<InstallProgress | null>(null)
let progressUnlisten: UnlistenFn | null = null
/** 内核信息(同时尝试从 resource 提取到 cores/ */
const refreshKernel = async () => {
try {
kernel.value = await invoke<KernelInfo>('downloader_kernel_info')
} catch (e) {
logger.error('获取内核信息失败: ' + e)
}
return kernel.value
}
/** 刷新进程状态 */
const refreshStatus = async () => {
try {
status.value = await invoke<DownloaderStatus>('downloader_status')
} catch (e) {
logger.error('获取进程状态失败: ' + e)
}
return status.value
}
const start = async () => {
await invoke('downloader_start')
await refreshStatus()
}
const stop = async () => {
await invoke('downloader_stop')
await refreshStatus()
}
const restart = async () => {
await invoke('downloader_restart')
await refreshStatus()
}
/** 等待 aria2 RPC 就绪(轮询 version 接口,最多等 10 秒) */
const waitForApi = async (timeoutMs = 10000): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
await invoke<Aria2Version>('downloader_version')
return true
} catch {
await new Promise((r) => setTimeout(r, 500))
}
}
return false
}
/** 获取 aria2 版本(仅运行时可用) */
const refreshVersion = async () => {
try {
const v = await invoke<Aria2Version>('downloader_version')
version.value = v.version
} catch {
version.value = ''
}
}
// ---------- 任务列表 ----------
const refreshActive = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_active')
activeTasks.value = res ?? []
} catch (e) {
logger.error('获取活跃任务失败: ' + e)
}
return activeTasks.value
}
const refreshWaiting = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_waiting')
waitingTasks.value = res ?? []
} catch (e) {
logger.error('获取等待任务失败: ' + e)
}
return waitingTasks.value
}
const refreshStopped = async () => {
try {
const res = await invoke<Aria2Task[]>('downloader_get_stopped')
stoppedTasks.value = res ?? []
} catch (e) {
logger.error('获取已完成任务失败: ' + e)
}
return stoppedTasks.value
}
/** 刷新全部任务(active + waiting + stopped */
const refreshAllTasks = async () => {
await Promise.all([refreshActive(), refreshWaiting(), refreshStopped()])
// 刷新后持久化历史快照
persistHistory()
}
const refreshGlobalStat = async () => {
try {
globalStat.value = await invoke<GlobalStat>('downloader_get_global_stat')
} catch (e) {
logger.error('获取全局统计失败: ' + e)
}
return globalStat.value
}
// ---------- 任务操作 ----------
const addUri = async (uris: string[], options?: Record<string, unknown>) => {
const opts = options ? (JSON.parse(JSON.stringify(options)) as unknown) : undefined
return await invoke<string>('downloader_add_uri', { uris, options: opts })
}
const pauseTask = async (gid: string) => {
await invoke('downloader_pause', { gid })
}
const unpauseTask = async (gid: string) => {
await invoke('downloader_unpause', { gid })
}
const removeTask = async (gid: string) => {
await invoke('downloader_remove', { gid })
}
const changeGlobalOption = async (options: Record<string, string>) => {
await invoke('downloader_change_global_option', { options })
}
// ---------- 设置 ----------
const loadSettings = async () => {
settings.value = await invoke<DownloaderSettings>('downloader_get_settings')
return settings.value
}
const saveSettings = async (s: DownloaderSettings) => {
await invoke('downloader_save_settings', { settings: s })
settings.value = s
}
// ---------- RPC 信息 ----------
const loadRpcInfo = async () => {
rpcInfo.value = await invoke<RpcInfo>('downloader_get_rpc_info')
return rpcInfo.value
}
// ---------- 内核更新 / 安装 ----------
const checkKernelUpdate = async (): Promise<KernelUpdateInfo> => {
return await invoke<KernelUpdateInfo>('downloader_check_kernel_update')
}
const updateKernel = async (mirrorPrefix: string = '') => {
await invoke('downloader_update_kernel', { mirrorPrefix })
await refreshKernel()
}
/**
* 首次安装内核:调用后端 install_kernel,监听 downloader-kernel-install-progress 事件更新进度
* @param mirrorPrefix 镜像源前缀(空串=GitHub 直连)
*/
const installKernel = async (mirrorPrefix: string = ''): Promise<void> => {
if (installing.value) return
installing.value = true
installProgress.value = {
stage: 'downloading',
percent: 0,
downloadedBytes: 0,
totalBytes: null,
message: '准备开始下载...'
}
if (!progressUnlisten) {
progressUnlisten = await listen<InstallProgress>('downloader-kernel-install-progress', (e) => {
installProgress.value = e.payload
})
}
try {
await invoke('downloader_install_kernel', { mirrorPrefix })
await refreshKernel()
} catch (e) {
logger.error('内核安装失败: ' + e)
throw e
} finally {
installing.value = false
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
}
const clearInstallProgress = () => {
installProgress.value = null
}
/** 用系统资源管理器打开目录(Rust 端绕过 opener scope 限制) */
const openDir = (path: string) => invoke<void>('downloader_open_dir', { path })
return {
// state
kernel,
status,
version,
settings,
rpcInfo,
activeTasks,
waitingTasks,
stoppedTasks,
globalStat,
installing,
installProgress,
// kernel & process
refreshKernel,
refreshStatus,
start,
stop,
restart,
waitForApi,
refreshVersion,
// tasks
refreshActive,
refreshWaiting,
refreshStopped,
refreshAllTasks,
refreshGlobalStat,
addUri,
pauseTask,
unpauseTask,
removeTask,
changeGlobalOption,
// settings
loadSettings,
saveSettings,
// rpc info
loadRpcInfo,
// kernel update / install
checkKernelUpdate,
updateKernel,
installKernel,
clearInstallProgress,
openDir,
loadHistory,
persistHistory
}
})
+75
View File
@@ -0,0 +1,75 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* 模块标签栏跨组件状态。
*
* 用于在模块详情页的 TabsList 因滚动消失时,
* 在顶部 TitleBar 中显示一组浮动切换按钮。
*
* 工作流程:
* 1. 模块(如 DownloaderModuleonMounted 时调用 registerTabs(),传入标签定义和当前值
* 2. 模块用 IntersectionObserver 或 scroll 监听检测 TabsList 可见性,调用 setFloatingVisible()
* 3. 模块通过 watch 将本地 activeTab 同步到 store
* 4. TitleBar 读取 store 的 tabs / activeTab / floatingVisible 渲染浮动切换器
* 5. 用户点击浮动切换器时调用 setActiveTab(),模块监听 store.activeTab 变化更新本地值
* 6. 模块 onUnmounted 时调用 unregisterTabs() 清理状态
*/
export interface ModuleTab {
value: string
label: string
}
export const useModuleTabsStore = defineStore('moduleTabs', () => {
/** 当前模块注册的标签列表(空表示无模块注册,TitleBar 不渲染) */
const tabs = ref<ModuleTab[]>([])
/** 当前活跃标签值 */
const activeTab = ref<string>('')
/** 是否显示浮动切换器(TabsList 滚出可视区时为 true */
const floatingVisible = ref<boolean>(false)
/** 模块注册标签(onMounted 时调用) */
const registerTabs = (tabList: ModuleTab[], current: string) => {
tabs.value = tabList
activeTab.value = current
floatingVisible.value = false
}
/** 模块注销标签(onUnmounted 时调用) */
const unregisterTabs = () => {
tabs.value = []
activeTab.value = ''
floatingVisible.value = false
}
/** 设置浮动切换器可见性 */
const setFloatingVisible = (visible: boolean) => {
// 仅在有注册标签时才允许显示
if (!visible) {
floatingVisible.value = false
return
}
if (tabs.value.length > 0) {
floatingVisible.value = true
}
}
/**
* 设置活跃标签(双向同步用)。
* - 模块本地 activeTab 变化时调用此方法同步到 store
* - TitleBar 点击时也调用此方法,模块通过 watch 感知变化
*/
const setActiveTab = (value: string) => {
activeTab.value = value
}
return {
tabs,
activeTab,
floatingVisible,
registerTabs,
unregisterTabs,
setFloatingVisible,
setActiveTab
}
})
+16
View File
@@ -182,4 +182,20 @@
100% {
left: 100%;
}
}
/* 模块 Tab 内容切换动画(全局,所有模块通用) */
.tab-animate {
animation: tabFadeSlide 0.35s cubic-bezier(0.22, 0.61, 0.36, 1);
}
@keyframes tabFadeSlide {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}