删除 MODULE_DEV_GUIDE.md
This commit is contained in:
@@ -1,618 +0,0 @@
|
|||||||
# 模块开发指南
|
|
||||||
|
|
||||||
本文档详细说明 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` | 模块前端组件 |
|
|
||||||
|
|
||||||
## 模块配置类型
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 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 步:编写模块配置
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 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` 中添加导入:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { moduleConfig as myModule } from './my-module'
|
|
||||||
|
|
||||||
const allModules: ModuleConfig[] = [
|
|
||||||
// ... 已有模块
|
|
||||||
myModule
|
|
||||||
]
|
|
||||||
|
|
||||||
moduleRegistry.registerAll(allModules)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 4 步:添加图标映射
|
|
||||||
|
|
||||||
在 `src/modules/icons.ts` 中添加:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Wrench } from '@lucide/vue' // 选择合适的图标
|
|
||||||
|
|
||||||
export const moduleIconMap: Record<string, Component> = {
|
|
||||||
// ... 已有映射
|
|
||||||
'my-module': Wrench
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第 5 步(可选):添加 Rust 后端命令
|
|
||||||
|
|
||||||
如果模块需要 Rust 后端支持,在 `src-tauri/src/` 中创建命令模块,并在 `lib.rs` 的 `invoke_handler` 中注册。
|
|
||||||
|
|
||||||
## 进程管理
|
|
||||||
|
|
||||||
需要管理外部子进程的模块(如 mihomo)通过 `ModuleProcessConfig` 声明进程配置。下载器模块使用进程内自建下载引擎,不涉及外部子进程管理。
|
|
||||||
|
|
||||||
### 配置示例
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 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
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
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 通知 | 显示"已禁用 {模块名}" | 显示"已启用 {模块名}" |
|
|
||||||
|
|
||||||
## 生命周期钩子
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ModuleLifecycle {
|
|
||||||
/** 模块首次加载时调用 */
|
|
||||||
onInit?: () => void | Promise<void>
|
|
||||||
/** 模块组件挂载时调用(切换到该模块) */
|
|
||||||
onActivate?: () => void | Promise<void>
|
|
||||||
/** 模块组件卸载时调用(切换离开该模块) */
|
|
||||||
onDeactivate?: () => void | Promise<void>
|
|
||||||
/** 模块被禁用时调用 */
|
|
||||||
onDisable?: () => void | Promise<void>
|
|
||||||
/** 模块被启用时调用 */
|
|
||||||
onEnable?: () => void | Promise<void>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
使用示例:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const moduleConfig: ModuleConfig = {
|
|
||||||
// ...
|
|
||||||
lifecycle: {
|
|
||||||
onEnable: async () => {
|
|
||||||
console.log('模块已启用')
|
|
||||||
// 初始化资源、建立连接等
|
|
||||||
},
|
|
||||||
onDisable: async () => {
|
|
||||||
console.log('模块已禁用')
|
|
||||||
// 释放资源、关闭连接等
|
|
||||||
},
|
|
||||||
onActivate: () => {
|
|
||||||
console.log('用户切换到本模块')
|
|
||||||
// 开始实时数据更新等
|
|
||||||
},
|
|
||||||
onDeactivate: () => {
|
|
||||||
console.log('用户离开本模块')
|
|
||||||
// 暂停实时更新以节省资源
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 全局搜索
|
|
||||||
|
|
||||||
模块通过 `searchItems` 声明可被全局搜索的功能项。用户在标题栏搜索框输入关键词时,匹配的搜索项会显示在结果列表中。
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
searchItems: [
|
|
||||||
{
|
|
||||||
title: '代理设置', // 显示标题
|
|
||||||
description: '配置网络代理', // 显示描述
|
|
||||||
keywords: ['代理', 'proxy', '网络'] // 匹配关键词
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
如果搜索项需要执行特定操作(如跳转到子页面、触发命令),在模块组件挂载时通过 `searchStore.registerAction` 注册:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
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` 持锁期间禁止 sleep**:`ProcessManager::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-value`**:reka-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 样式。
|
|
||||||
|
|
||||||
### 数据一致性与自愈
|
|
||||||
|
|
||||||
- **配置文件与磁盘 reconcile**:settings.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.rs` 的 `quit_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 平台该函数为空实现,无需条件编译。
|
|
||||||
|
|
||||||
```rust
|
|
||||||
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() 已移到后台线程**:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
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.zip`(v1/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` 分支如果缺 `:key`,Vue 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 也要加。
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<Transition name="fade" mode="out-in">
|
|
||||||
<ComponentA v-if="cond" key="a" />
|
|
||||||
<div v-else key="empty">占位</div>
|
|
||||||
</Transition>
|
|
||||||
```
|
|
||||||
|
|
||||||
`ModuleContainer.vue` 和 `ProxyModule.vue` 的 Progress 区块已修复,新模块开发时注意。
|
|
||||||
|
|
||||||
### 前端:模块内 Tabs 顶部固定模式
|
|
||||||
|
|
||||||
模块根容器用 `h-full overflow-hidden flex flex-col`,Tabs 用 `flex-1 min-h-0 flex flex-col`,TabsList 加 `shrink-0`,TabsContent 加 `flex-1 min-h-0 overflow-y-auto`:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<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.ts` 的 `installKernel` 确立了模式:
|
|
||||||
|
|
||||||
- 监听在方法调用时注册,`finally` 块中取消
|
|
||||||
- 用模块级变量保存 `UnlistenFn`,避免重复注册
|
|
||||||
- 错误事件由后端保证 emit(前端不重复弹 toast,统一由 watch 处理)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
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 的模块都可用,代理/下载器模块已接入):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// 模块 <script setup> 顶部
|
|
||||||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
|
||||||
|
|
||||||
const activeTab = ref('overview')
|
|
||||||
const tabsListRef = useModuleTabs(activeTab, [
|
|
||||||
{ value: 'overview', label: '概览' },
|
|
||||||
{ value: 'settings', label: '设置' }
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- 模板中给 TabsList 包一层带 ref 的 div -->
|
|
||||||
<Tabs v-model="activeTab">
|
|
||||||
<div ref="tabsListRef">
|
|
||||||
<TabsList>...</TabsList>
|
|
||||||
</div>
|
|
||||||
<TabsContent .../>
|
|
||||||
</Tabs>
|
|
||||||
```
|
|
||||||
|
|
||||||
**工作原理**:
|
|
||||||
- composable 内部在 `onMounted` 时注册标签到 `moduleTabsStore`,`onUnmounted` 时注销
|
|
||||||
- 用 `IntersectionObserver`(`rootMargin: '-44px 0px 0px 0px'` 裁剪 TitleBar 高度)监听 TabsList 可见性
|
|
||||||
- 滚动遮挡时 `TitleBar` 中"Thing"标题右侧自动显示浮动切换按钮(带淡入+左滑动画)
|
|
||||||
- 双向 watch 同步本地 `activeTab` 与 store,用户点击 TitleBar 浮动按钮也能切换模块内 Tab
|
|
||||||
|
|
||||||
**约束**:TitleBar 高度固定 40px(h-10),composable 已用 44px 裁剪(含 4px 缓冲);store 是单例,一个模块同一时间只能注册一组标签。
|
|
||||||
|
|
||||||
### 前端:内核路径用 %APPDATA% 简化显示
|
|
||||||
|
|
||||||
内核路径较长(如 `C:\Users\xxx\AppData\Roaming\thing.lfeng.me\proxy\cores\mihomo.exe`),展示时用 `%APPDATA%` 替换前缀,并提供复制完整路径 / 在文件夹中显示两个按钮。代理和下载器模块的内核管理区块已采用此模式。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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-progress`、`download-complete`、`download-added` 实时更新任务状态
|
|
||||||
- 引擎在 `Storage::new` 时自动创建数据目录,`save` 时兜底重建父目录,避免路径不存在错误
|
|
||||||
|
|
||||||
此模式适用于任何"引擎在进程内运行、状态需持久化"的场景。
|
|
||||||
|
|
||||||
### 前端:轻量设置 Dialog(替代独立 Tab)
|
|
||||||
|
|
||||||
模块设置项较多时,传统做法是单独开一个"设置"Tab。但任务页工具栏需要快速调整少量核心设置(如下载目录、并发数),切到设置 Tab 再切回来体验割裂。
|
|
||||||
|
|
||||||
**模式**:在任务工具栏放一个"下载设置"按钮,点击弹出 Dialog,包含核心设置项(与设置页共用 `store.settings`),保存时调用 `handleSaveSettings`(引擎立即应用新配置,无需重启)并自动关闭弹窗。完整设置仍保留在"设置"Tab。
|
|
||||||
|
|
||||||
适用场景:需要在任务页快速调整的少量高频设置;若设置项不多,可完全用 Dialog 替代设置 Tab。
|
|
||||||
|
|
||||||
### 后端:opener 插件 scope 限制与绕过
|
|
||||||
|
|
||||||
`tauri-plugin-opener` 的 `opener:allow-open-path` 权限默认 **无 scope**,IPC `open_path` 命令会拒绝任何路径并报错 `Not allowed to open path`。
|
|
||||||
|
|
||||||
**解决方案**:在 Rust 端写一个自定义命令,用 `OpenerExt::opener().open_path()` 直接调用(绕过 IPC scope 检查):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
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.rs` 的 `invoke_handler` 中注册,前端通过 `invoke('my_open_dir', { path })` 调用。下载器模块的"打开下载目录"已采用此方案。
|
|
||||||
|
|
||||||
### 前端:剩余时间(ETA)可读化
|
|
||||||
|
|
||||||
下载卡片直接显示 `(total-completed)/speed` 会得到 `17.217666215634114 B` 这种难懂的字节数。应格式化为时间:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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.rs` 的 `RunEvent::ExitRequested` 分支(P0-1/V7):`quit_app` 命令与托盘退出项都只触发 `app.exit(0)`,清理逻辑(`cleanup_on_exit` + `stop_all`)只在该分支执行一次。新增管理子进程的模块时,在 `ExitRequested` 分支注册自己的 `cleanup_on_exit` 即可,不要在命令或托盘路径重复清理。
|
|
||||||
Reference in New Issue
Block a user