Files
Thing/MODULE_DEV_GUIDE.md
T
2026-08-06 10:33:16 +08:00

27 KiB
Raw Blame History

模块开发指南

本文档详细说明 Thing 应用的模块系统架构,帮助开发者快速理解并创建新模块。

架构概述

Thing 采用统一模块注册机制,每个模块通过 index.ts 自描述其全部配置(名称、图标、组件、搜索项、进程配置、生命周期钩子等),由中央注册表 moduleRegistry 统一管理。

┌─────────────────────────────────────────────────────┐
│                    main.ts                          │
│         import './modules' (触发注册)                │
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│              src/modules/index.ts                    │
│        聚合入口:导入所有模块配置并注册               │
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│           src/modules/registry.ts                    │
│     ModuleRegistry 单例:注册 / 查询 / 组件懒加载     │
└──────┬───────────────┬───────────────┬──────────────┘
       │               │               │
       ▼               ▼               ▼
  App.vue         Sidebar.vue     GeneralSettings
  (组件加载)       (侧边栏导航)     (模块开关管理)

核心文件说明

文件 职责
src/types/module.ts 模块配置类型定义
src/modules/registry.ts 注册表单例:注册、查询、组件懒加载缓存
src/modules/index.ts 聚合入口:导入并注册所有模块
src/modules/icons.ts 模块图标映射表
src/modules/<name>/index.ts 各模块的配置声明
src/modules/<name>/*.vue 模块前端组件

模块配置类型

// src/types/module.ts

interface ModuleConfig {
  /** 模块唯一标识(如 'proxy'、'clipboard' */
  id: string
  /** 显示名称 */
  name: string
  /** 图标标识(对应 icons.ts 中的 key */
  icon: string
  /** 模块描述(显示在设置界面的模块管理中) */
  description: string
  /** 模块分类 */
  category: 'network' | 'tool' | 'system' | 'media'
  /** 默认是否启用(默认 true) */
  defaultEnabled?: boolean
  /** 是否为内置模块(不可禁用,如设置模块) */
  builtin?: boolean
  /** 懒加载组件的 loader 函数(推荐) */
  loader?: () => Promise<{ default: Component }>
  /** 直接组件引用(内置模块可用,无需懒加载) */
  component?: Component
  /** 全局搜索项 */
  searchItems?: SearchIndexItem[]
  /** 进程配置(需要管理子进程的模块填写) */
  process?: ModuleProcessConfig
  /** 生命周期钩子 */
  lifecycle?: ModuleLifecycle
  /** 排序权重(数值越小越靠前,默认 100) */
  order?: number
}

创建新模块

第 1 步:创建模块目录

src/modules/my-module/
├── index.ts              # 模块配置
└── MyModule.vue          # 前端组件

第 2 步:编写模块配置

// src/modules/my-module/index.ts
import type { ModuleConfig } from '@/types/module'

export const moduleConfig: ModuleConfig = {
  id: 'my-module',
  name: '我的模块',
  icon: 'my-module',           // 需在 icons.ts 中添加映射
  description: '模块功能描述',
  category: 'tool',
  defaultEnabled: true,
  loader: () => import('./MyModule.vue'),
  searchItems: [
    {
      title: '功能名称',
      description: '功能描述',
      keywords: ['关键词1', '关键词2', 'keyword']
    }
  ],
  order: 70
}

第 3 步:注册模块

src/modules/index.ts 中添加导入:

import { moduleConfig as myModule } from './my-module'

const allModules: ModuleConfig[] = [
  // ... 已有模块
  myModule
]

moduleRegistry.registerAll(allModules)

第 4 步:添加图标映射

src/modules/icons.ts 中添加:

import { Wrench } from '@lucide/vue'  // 选择合适的图标

export const moduleIconMap: Record<string, Component> = {
  // ... 已有映射
  'my-module': Wrench
}

第 5 步(可选):添加 Rust 后端命令

如果模块需要 Rust 后端支持,在 src-tauri/src/ 中创建命令模块,并在 lib.rsinvoke_handler 中注册。

进程管理

需要管理外部子进程的模块(如 mihomo)通过 ModuleProcessConfig 声明进程配置。下载器模块使用进程内自建下载引擎,不涉及外部子进程管理。

配置示例

// src/modules/proxy/index.ts
export const moduleConfig: ModuleConfig = {
  // ...
  process: {
    name: 'mihomo',              // 进程标识
    executable: '',              // 可执行文件路径(运行时确定)
    args: ['-f', 'config.yaml'], // 启动参数
    autoStart: false,            // 模块启用时是否自动启动
    restartOnCrash: true,        // 崩溃后自动重启
    maxRestarts: 3               // 最大重启次数(0 = 不限制)
  }
}

前端 API

import { useProcessStore } from '@/stores/processStore'

const processStore = useProcessStore()

// 通过模块 ID 启动进程(自动读取模块配置)
await processStore.startByModule('proxy')

// 停止进程
await processStore.stopByModule('proxy')

// 获取进程状态(从缓存读取)
const status = processStore.getProcessStatus('proxy')
// status.status: 'running' | 'stopped' | 'crashed' | 'starting'

// 刷新所有进程状态
await processStore.refreshAll()

自动化行为

模块启用/禁用时,appStore.toggleModule 会自动处理:

操作 禁用模块 启用模块
进程 停止运行中的进程 autoStart 为 true,启动进程
搜索项 移除该模块的搜索项 恢复该模块的搜索项
组件缓存 清除组件缓存,释放内存 下次访问时重新懒加载
生命周期 调用 onDisable 钩子 调用 onEnable 钩子
侧边栏 从侧边栏隐藏 在侧边栏显示
Toast 通知 显示"已禁用 {模块名}" 显示"已启用 {模块名}"

生命周期钩子

interface ModuleLifecycle {
  /** 模块首次加载时调用 */
  onInit?: () => void | Promise<void>
  /** 模块组件挂载时调用(切换到该模块) */
  onActivate?: () => void | Promise<void>
  /** 模块组件卸载时调用(切换离开该模块) */
  onDeactivate?: () => void | Promise<void>
  /** 模块被禁用时调用 */
  onDisable?: () => void | Promise<void>
  /** 模块被启用时调用 */
  onEnable?: () => void | Promise<void>
}

使用示例:

export const moduleConfig: ModuleConfig = {
  // ...
  lifecycle: {
    onEnable: async () => {
      console.log('模块已启用')
      // 初始化资源、建立连接等
    },
    onDisable: async () => {
      console.log('模块已禁用')
      // 释放资源、关闭连接等
    },
    onActivate: () => {
      console.log('用户切换到本模块')
      // 开始实时数据更新等
    },
    onDeactivate: () => {
      console.log('用户离开本模块')
      // 暂停实时更新以节省资源
    }
  }
}

全局搜索

模块通过 searchItems 声明可被全局搜索的功能项。用户在标题栏搜索框输入关键词时,匹配的搜索项会显示在结果列表中。

searchItems: [
  {
    title: '代理设置',           // 显示标题
    description: '配置网络代理',  // 显示描述
    keywords: ['代理', 'proxy', '网络']  // 匹配关键词
  }
]

如果搜索项需要执行特定操作(如跳转到子页面、触发命令),在模块组件挂载时通过 searchStore.registerAction 注册:

import { useSearchStore } from '@/stores/searchStore'

const searchStore = useSearchStore()

onMounted(() => {
  // 注册第 0 个搜索项的 action
  searchStore.registerAction('my-module', 0, () => {
    // 跳转到特定子页面或执行操作
    activeTab.value = 'settings'
  })
})

模块分类

分类 说明 适用场景
network 网络相关 代理、下载器、网络工具
tool 实用工具 剪贴板、文件搜索、文本处理
system 系统相关 硬件监控、系统设置
media 媒体相关 截图、录屏、图片处理

分类目前用于元信息标记,未来可用于设置界面的分组展示。

模块禁用机制详解

当用户在设置页面切换模块开关时:

用户点击开关
    │
    ▼
appStore.toggleModule(moduleId, enabled)
    │
    ├─ 禁用时:
    │   ├─ searchStore.unregisterModule(moduleId)   // 移除搜索项
    │   ├─ processStore.stopByModule(moduleId)       // 停止进程
    │   ├─ config.lifecycle?.onDisable?.()           // 生命周期钩子
    │   ├─ moduleRegistry.clearComponentCache(id)    // 清除组件缓存
    │   ├─ modules[id].enabled = false               // 更新状态
    │   └─ toast.success('已禁用 {模块名}')           // 通知用户
    │
    └─ 启用时:
        ├─ modules[id].enabled = true                // 更新状态
        ├─ config.lifecycle?.onEnable?.()            // 生命周期钩子
        ├─ searchStore.registerItem(...)             // 恢复搜索项
        ├─ processStore.startByModule(moduleId)      // 启动进程(若 autoStart
        └─ toast.success('已启用 {模块名}')            // 通知用户

禁用后的效果:

  • 模块从侧边栏导航中隐藏
  • 模块的搜索项从全局搜索中移除
  • 模块的后台进程被停止
  • 模块的组件缓存被清除,释放内存
  • 如果当前正在查看被禁用的模块,自动切换到第一个可用模块

注意事项

  1. 模块 ID 必须唯一:重复注册会被忽略并输出警告
  2. 图标必须映射:模块配置中的 icon 字符串必须在 icons.ts 中有对应映射,否则回退到 Settings 图标
  3. 懒加载优先:使用 loader 而非 component,避免首屏加载所有模块代码
  4. 避免循环依赖:模块的 index.ts 只导出配置,不导入其他模块的 store
  5. 进程配置的 executable:通常留空,由模块组件在运行时根据用户设置确定实际路径
  6. 内置模块:设置 builtin: true 的模块不可被用户禁用,开关处于禁用状态

跨模块开发经验(代理模块沉淀)

以下要点来自代理模块开发,对后续涉及子进程管理、外部 API 交互、shadcn-vue 组件使用的模块同样适用。

Tauri 命令与主线程

  • 同步命令(pub fn)会阻塞主线程:Tauri 的同步命令在主线程执行,其内部的 std::thread::sleep、磁盘 I/O、网络请求会阻塞所有 async 命令的调度。涉及等待/阻塞操作的命令必须声明为 pub async fn,并用 tauri::async_runtime::spawn_blocking(|| { std::thread::sleep(...) }).await 将阻塞操作放到线程池。
  • Windows 端口释放有延迟child.kill() + child.wait() 后 TCP 端口不会立即可用,需等待约 800ms 再重新绑定。重启类命令应预留此延迟。
  • Mutex 持锁期间禁止 sleepProcessManager::check_and_cleanup 等持锁函数中不要执行长时间 sleep,否则会阻塞所有需要该锁的命令(如状态查询)。应先释放锁再 sleep,或移出临界区。
  • 进程监控线程start_monitoring_thread 每 3 秒检查一次进程状态,崩溃时自动重启(可配置 maxRestarts)。前端通过监听 process-status-changed 事件更新 UI。

外部 API 交互

  • API 就绪轮询:子进程 spawn 后 API 不会立即可用(需初始化配置、加载 geo 文件等)。前端应在请求前轮询健康检查接口(如 /version),500ms 间隔、10s 超时。
  • 缓存配置避免频繁读盘:后端 Manager 每次方法调用都从磁盘读 settings 会拖慢批量操作。建议在 Manager 内维护内存缓存,save_settings 时同步更新。
  • 并发请求限流:批量测速等场景不要一次性 Promise.all 全部请求,应分批(如每批 20 个),避免压垮子进程。

shadcn-vue / reka-ui 注意事项

  • Select 禁止空字符串 value<SelectItem value=""> 会触发警告并失效。使用哨兵值(如 __default____all__)替代空字符串,在 @update:model-value 回调中转回空值。
  • Select 双击问题reka-ui Select 的 DismissableLayer + closeAutoFocus 会导致连续点击两个 Select 时第一次点击仅关闭上一个、需第二次点击才打开下一个。当前未完美解决,建议同一界面避免放置过多相邻 Select。
  • Switch 使用 model-value / update:model-valuereka-ui v2+ 的 Switch 不再使用 checked / update:checked
  • Sonner toast 不可见:通常是缺少 vue-sonner/lib/index.css 导入,而非 z-index 问题。确保在入口处导入该 CSS。
  • AlertDialog 替代 confirm():原生 confirm() 在 Tauri WebView 中样式不一致,使用 shadcn-vue AlertDialog 封装 Promise 化的 showConfirm() 函数,支持 destructive 样式。

数据一致性与自愈

  • 配置文件与磁盘 reconcilesettings.json 中的列表(如订阅 profiles)可能与磁盘文件不同步(用户手动删除、反序列化失败被 default 覆盖)。每次 load_settings 时应扫描磁盘补全缺失条目,并在 currentProfile 为 null 时自动指向第一个。
  • 操作顺序防重复:导入文件后写入 settings 时,若先写文件再 load_settings(含 reconcile 扫盘),reconcile 会扫到新文件添加一次,随后 push 又添加一次。应先 load_settings → 写文件 → push(加去重保护)。
  • 子进程崩溃循环防护:子进程因依赖文件损坏(如 geo 数据库)崩溃时,restart_on_crash 会反复重启。需配置备用下载源(如 jsdelivr 镜像)避免无代理时 GitHub 超时。

UI 细节

  • 瀑布流布局避免卡片等高撑开:使用 columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid 替代 grid,让卡片按内容高度自然排列。
  • 模块禁用清理系统状态:模块 onDisable 钩子应清理系统级副作用(如系统代理注册表项),避免模块停用后遗留导致系统异常。
  • 应用退出清理:在 lib.rsquit_app 命令和托盘退出事件中都要调用 cleanup_on_exit + stop_all,确保任何退出路径都清理干净。

近期重要变更(影响其他模块开发)

以下是最近几次改动中确立的约定和模式,开发新模块时需遵循。

后端:子进程启动必须隐藏控制台窗口

process_manager.rs 暴露了公共函数 setup_creation_flags(cmd: &mut Command)Windows 上会设置 CREATE_NO_WINDOW 标志。

所有用 std::process::Command 启动外部程序的地方都必须调用它,否则会弹出黑色控制台窗口(即使程序是后台运行)。包括:

  • ProcessManager::start() 启动 mihomo 等内核
  • ProcessManager::check_and_cleanup() 崩溃重启
  • 任何调用 mihomo.exe -v 等查询版本的场景

跨平台:非 Windows 平台该函数为空实现,无需条件编译。

use crate::process_manager::setup_creation_flags;

let mut cmd = std::process::Command::new(&path);
cmd.arg("-v");
setup_creation_flags(&mut cmd);  // 必须调用
cmd.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());

后端:长时间 wait() 不要阻塞主线程

ProcessManager::stop()child.kill() 是快速的,但 child.wait() 可能阻塞几十到几百毫秒。wait() 已移到后台线程

thread::spawn(move || {
    let _ = entry.child.wait();
});

新模块若有类似的"终止外部进程"逻辑,也应遵循此模式,避免 Tauri 命令阻塞导致前端卡顿。

后端:内核/二进制下载用流式 + 事件推送

mihomo_manager/ 目录(kernel.rs)的 install_kernel 确立了"下载二进制资源"的标准模式,未来若有其他模块需要下载外部内核时应复用:

  • 流式下载reqwest::Response::bytes_stream() + futures_util::StreamExt,避免大文件一次性读入内存
  • 进度事件:通过 app.emit("xxx-install-progress", progress) 推送,事件载荷结构参考 InstallProgress
  • 事件节流:仅在百分比变化 ≥1% 时 emit,避免事件轰炸
  • zip 解压:用 zip crate(纯 Rust),不要用 PowerShell Expand-Archive(有执行策略问题)
  • 文件名匹配:解压后用 find_exe_in_dir 查找 .exe(zip 内文件名可能含版本号,不是固定名字),找到后重命名为标准名

后端:GitHub API rate limit 规避

check_kernel_update 采用 API 优先 + 重定向回退 策略:

  1. 先调 api.github.com/.../releases/latest(能拿完整资产列表,命名变化时更健壮)
  2. 失败(403 rate limit / 网络错误)时回退到访问 github.com/.../releases/latest,从 302 重定向的最终 URL 提取版本号,按稳定命名规则构造下载 URL

新模块若需要查 GitHub 最新版本,应复用此模式,不要直接调 API(未认证 60次/小时/IP 极易超限)。

后端:资产命名规则适配

mihomo v1.19+ 改了 Windows 资产命名,按 CPU 微架构分级:

  • 旧:mihomo-windows-amd64-vX.X.X.zip(已废弃)
  • 新:mihomo-windows-amd64-v3-vX.X.X.zipv1/v2/v3 对应 CPU level

fetch_latest_via_api 中的匹配优先级:v3 标准 > v3-go124 > v3-go123 > v3 其他 > v2 > v1 > 旧命名。若未来其他内核也有类似分级,参考此优先级策略。

前端:Transition 内的 v-if/v-else 必须加 key

这是 dev 模式的坑<Transition mode="out-in"> 内的 v-if/v-else 分支如果缺 :keyVue 3.5.x dev 模式下会触发 __vnode 写入竞态,导致:

  • 控制台报错 Cannot set properties of null (setting '__vnode')
  • vnode 树损坏,所有事件派发失效(按钮点击没反应)
  • build 模式不报错(生产构建剥除了 __vnode instrumentation),容易漏掉

约定<Transition> 内所有分支(v-if/v-else-if/v-else)都必须加 :key,即使是原生 div 也要加。

<Transition name="fade" mode="out-in">
  <ComponentA v-if="cond" key="a" />
  <div v-else key="empty">占位</div>
</Transition>

ModuleContainer.vueProxyModule.vue 的 Progress 区块已修复,新模块开发时注意。

前端:模块内 Tabs 顶部固定模式

模块根容器用 h-full overflow-hidden flex flex-colTabs 用 flex-1 min-h-0 flex flex-colTabsList 加 shrink-0TabsContent 加 flex-1 min-h-0 overflow-y-auto

<div class="h-full p-6 overflow-hidden flex flex-col">
  <Tabs v-model="tab" class="flex-1 min-h-0 flex flex-col">
    <TabsList class="shrink-0">...</TabsList>
    <TabsContent value="x" class="flex-1 min-h-0 overflow-y-auto">...</TabsContent>
  </Tabs>
</div>

这样 TabsList 固定在顶部,只有 TabsContent 滚动。min-h-0 是 flex 子元素 overflow 生效的关键,不能省。

前端:Tauri 事件监听需在 store 中管理生命周期

proxyStore.tsinstallKernel 确立了模式:

  • 监听在方法调用时注册,finally 块中取消
  • 用模块级变量保存 UnlistenFn,避免重复注册
  • 错误事件由后端保证 emit(前端不重复弹 toast,统一由 watch 处理)
let progressUnlisten: UnlistenFn | null = null
const installKernel = async () => {
  if (!progressUnlisten) {
    progressUnlisten = await listen<Progress>('xxx-progress', (e) => {
      progress.value = e.payload
    })
  }
  try {
    await invoke('xxx_command')
  } finally {
    if (progressUnlisten) {
      progressUnlisten()
      progressUnlisten = null
    }
  }
}

前端:窗口隐藏前 blur 焦点

TitleBar 的关闭按钮实际是 hide() 到托盘。webview 快速隐藏时浏览器 mouseleave 可能不触发,导致从托盘恢复后按钮仍显示 hover 高亮。

修复hide() 前调用 document.activeElement.blur(),并监听 onFocusChanged 在窗口重新获得焦点时再 blur 一次。新模块若有类似的"隐藏窗口"操作(如全局快捷键隐藏),同样需要 blur。

前端:浮动标签切换器(TabsList 滚动遮挡时在 TitleBar 显示)

模块详情页内容滚动时,顶部 TabsList 会被 TitleBar 遮挡,导致用户必须滚回顶部才能切换 Tab。已抽取通用 composable src/lib/use-module-tabs.ts 自动处理。

接入方式(任何使用 Tabs 的模块都可用,代理/下载器模块已接入):

// 模块 <script setup> 顶部
import { useModuleTabs } from '@/lib/use-module-tabs'

const activeTab = ref('overview')
const tabsListRef = useModuleTabs(activeTab, [
  { value: 'overview', label: '概览' },
  { value: 'settings', label: '设置' }
])
<!-- 模板中给 TabsList 包一层带 ref  div -->
<Tabs v-model="activeTab">
  <div ref="tabsListRef">
    <TabsList>...</TabsList>
  </div>
  <TabsContent .../>
</Tabs>

工作原理

  • composable 内部在 onMounted 时注册标签到 moduleTabsStoreonUnmounted 时注销
  • IntersectionObserverrootMargin: '-44px 0px 0px 0px' 裁剪 TitleBar 高度)监听 TabsList 可见性
  • 滚动遮挡时 TitleBar 中"Thing"标题右侧自动显示浮动切换按钮(带淡入+左滑动画)
  • 双向 watch 同步本地 activeTab 与 store,用户点击 TitleBar 浮动按钮也能切换模块内 Tab

约束TitleBar 高度固定 40pxh-10),composable 已用 44px 裁剪(含 4px 缓冲);store 是单例,一个模块同一时间只能注册一组标签。

前端:内核路径用 %APPDATA% 简化显示

内核路径较长(如 C:\Users\xxx\AppData\Roaming\thing.lfeng.me\proxy\cores\mihomo.exe),展示时用 %APPDATA% 替换前缀,并提供复制完整路径 / 在文件夹中显示两个按钮。代理和下载器模块的内核管理区块已采用此模式。

import { appDataDir } from '@tauri-apps/api/path'

const appDataPath = ref('')
onMounted(async () => {
  try { appDataPath.value = await appDataDir() } catch {}
})

const pathDisplay = computed(() => {
  const p = store.kernel?.path
  if (!p) return ''
  if (appDataPath.value && p.toLowerCase().startsWith(appDataPath.value.toLowerCase())) {
    return '%APPDATA%' + p.slice(appDataPath.value.length)
  }
  return p
})

<title> 放完整路径,显示文本用 pathDisplay,复制时复制完整路径。

前端:任务历史持久化(下载器模块模式)

下载引擎在进程内运行,状态通过 engine_state.json 持久化到磁盘。前端 store 的做法:

  • onMounted 时调用 store.init(),一次性加载 status/settings/extensionInfo/tasks
  • 通过 Tauri 事件 download-progressdownload-completedownload-added 实时更新任务状态
  • 引擎在 Storage::new 时自动创建数据目录,save 时兜底重建父目录,避免路径不存在错误

此模式适用于任何"引擎在进程内运行、状态需持久化"的场景。

前端:轻量设置 Dialog(替代独立 Tab)

模块设置项较多时,传统做法是单独开一个"设置"Tab。但任务页工具栏需要快速调整少量核心设置(如下载目录、并发数),切到设置 Tab 再切回来体验割裂。

模式:在任务工具栏放一个"下载设置"按钮,点击弹出 Dialog,包含核心设置项(与设置页共用 store.settings),保存时调用 handleSaveSettings(引擎立即应用新配置,无需重启)并自动关闭弹窗。完整设置仍保留在"设置"Tab。

适用场景:需要在任务页快速调整的少量高频设置;若设置项不多,可完全用 Dialog 替代设置 Tab。

后端:opener 插件 scope 限制与绕过

tauri-plugin-openeropener:allow-open-path 权限默认 无 scopeIPC open_path 命令会拒绝任何路径并报错 Not allowed to open path

解决方案:在 Rust 端写一个自定义命令,用 OpenerExt::opener().open_path() 直接调用(绕过 IPC scope 检查):

use tauri_plugin_opener::OpenerExt;

#[tauri::command]
pub fn my_open_dir(app: AppHandle, path: String) -> Result<(), String> {
    let p = std::path::Path::new(&path);
    if !p.exists() {
        return Err(format!("路径不存在: {}", path));
    }
    app.opener().open_path(path, None::<&str>).map_err(|e| e.to_string())
}

lib.rsinvoke_handler 中注册,前端通过 invoke('my_open_dir', { path }) 调用。下载器模块的"打开下载目录"已采用此方案。

前端:剩余时间(ETA)可读化

下载卡片直接显示 (total-completed)/speed 会得到 17.217666215634114 B 这种难懂的字节数。应格式化为时间:

const formatEta = (seconds: number): string => {
  if (!isFinite(seconds) || seconds <= 0) return ''
  if (seconds < 60) return `剩余 ${Math.ceil(seconds)} 秒`
  if (seconds < 3600) {
    const m = Math.floor(seconds / 60)
    const s = Math.ceil(seconds % 60)
    return `剩余 ${m}${s} 秒`
  }
  const h = Math.floor(seconds / 3600)
  const m = Math.ceil((seconds % 3600) / 60)
  return `剩余 ${h} 小时 ${m} 分`
}

getEta(task) = (totalLength - completedLength) / downloadSpeed,speed≤0 时返回 0(不显示)。

前端:状态统计用 Badge 替代纯文本

下载任务工具栏的速度/活跃/等待/已完成数量,以及代理概览页的内核状态/版本,从纯文本改为 Badge variant="secondary" 或带颜色的 Badge(绿色已安装/红色未安装),视觉更醒目。新模块的状态展示建议统一用 Badge。

后端:应用退出清理(单一出口)

退出清理已收敛到 lib.rsRunEvent::ExitRequested 分支(P0-1/V7):quit_app 命令与托盘退出项都只触发 app.exit(0),清理逻辑(cleanup_on_exit + stop_all)只在该分支执行一次。新增管理子进程的模块时,在 ExitRequested 分支注册自己的 cleanup_on_exit 即可,不要在命令或托盘路径重复清理。