主界面修改及代理模块初始化
This commit is contained in:
+65
-44
@@ -1,64 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, shallowRef, markRaw, type Component } from 'vue'
|
||||
import { ref, onMounted, shallowRef, computed, watch, type Component } from 'vue'
|
||||
import TitleBar from '@/components/layout/TitleBar.vue'
|
||||
import Sidebar from '@/components/layout/Sidebar.vue'
|
||||
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
||||
import GeneralSettings from '@/modules/general/GeneralSettings.vue'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { moduleRegistry } from '@/modules/registry'
|
||||
import type { ModuleMeta } from '@/types/module'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
interface ModuleMeta {
|
||||
/** 侧边栏 / 标题栏需要的模块信息(id + name + icon) */
|
||||
interface NavModule {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
// 同步加载的模块直接传 Component;懒加载的传 import 工厂
|
||||
loader?: () => Promise<{ default: Component }>
|
||||
component?: Component
|
||||
}
|
||||
|
||||
// 常规设置是默认可见且轻量的,直接同步引入
|
||||
// 其他业务模块较大且首屏不一定需要,懒加载
|
||||
const modules: ModuleMeta[] = [
|
||||
{ id: 'proxy', name: '代理管理', icon: 'proxy', loader: () => import('@/modules/proxy/ProxyModule.vue') },
|
||||
{ id: 'clipboard', name: '剪贴板', icon: 'clipboard', loader: () => import('@/modules/clipboard/ClipboardModule.vue') },
|
||||
{ id: 'screenshot', name: '截图', icon: 'screenshot', loader: () => import('@/modules/screenshot/ScreenshotModule.vue') },
|
||||
{ id: 'monitor', name: '硬件监控', icon: 'monitor', loader: () => import('@/modules/monitor/MonitorModule.vue') },
|
||||
{ id: 'downloader', name: '下载器', icon: 'downloader', loader: () => import('@/modules/downloader/DownloaderModule.vue') },
|
||||
{ id: 'finder', name: '文件搜索', icon: 'finder', loader: () => import('@/modules/finder/FinderModule.vue') },
|
||||
{ id: 'settings', name: '常规设置', icon: 'settings', component: markRaw(GeneralSettings) }
|
||||
]
|
||||
/** 从注册表元信息转换为导航用的精简结构 */
|
||||
const toNavModule = (meta: ModuleMeta): NavModule => ({
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
icon: meta.icon
|
||||
})
|
||||
|
||||
const activeModule = ref('proxy')
|
||||
|
||||
// 当前激活的组件实例(shallowRef 适合大组件)
|
||||
const activeComponent = shallowRef<Component | null>(null)
|
||||
|
||||
// 加载模块组件
|
||||
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
|
||||
const availableModules = computed<NavModule[]>(() => {
|
||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||
const allModules = moduleRegistry
|
||||
.getAllMetas()
|
||||
.filter(m => m.builtin || enabledIds.includes(m.id))
|
||||
.map(toNavModule)
|
||||
|
||||
// 按 moduleOrder 排序,settings 始终在末尾
|
||||
return allModules.sort((a, b) => {
|
||||
if (a.id === 'settings') return 1
|
||||
if (b.id === 'settings') return -1
|
||||
const aIdx = appStore.moduleOrder.indexOf(a.id)
|
||||
const bIdx = appStore.moduleOrder.indexOf(b.id)
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
})
|
||||
})
|
||||
|
||||
const loadModule = async (moduleId: string) => {
|
||||
const m = modules.find(mod => mod.id === moduleId)
|
||||
if (!m) {
|
||||
activeComponent.value = null
|
||||
return
|
||||
}
|
||||
if (m.component) {
|
||||
activeComponent.value = m.component
|
||||
return
|
||||
}
|
||||
if (m.loader) {
|
||||
try {
|
||||
const mod = await m.loader()
|
||||
// 缓存到 component,避免重复加载
|
||||
m.component = markRaw(mod.default)
|
||||
activeComponent.value = m.component
|
||||
} catch (e) {
|
||||
console.error(`Failed to load module ${moduleId}:`, e)
|
||||
}
|
||||
}
|
||||
const component = await moduleRegistry.loadComponent(moduleId)
|
||||
activeComponent.value = component
|
||||
|
||||
// 调用模块的 onActivate 生命周期钩子
|
||||
const config = moduleRegistry.getConfig(moduleId)
|
||||
config?.lifecycle?.onActivate?.()
|
||||
}
|
||||
|
||||
const handleModuleChange = (moduleId: string) => {
|
||||
// 调用上一个模块的 onDeactivate 钩子
|
||||
const prevConfig = moduleRegistry.getConfig(activeModule.value)
|
||||
prevConfig?.lifecycle?.onDeactivate?.()
|
||||
|
||||
activeModule.value = moduleId
|
||||
loadModule(moduleId)
|
||||
}
|
||||
@@ -68,8 +72,24 @@ const handleSearch = (moduleId: string) => {
|
||||
loadModule(moduleId)
|
||||
}
|
||||
|
||||
const getFallbackModule = () => {
|
||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||
const fallback = moduleRegistry.getAllMetas().find(
|
||||
m => !m.builtin && enabledIds.includes(m.id)
|
||||
)
|
||||
return fallback?.id || 'settings'
|
||||
}
|
||||
|
||||
watch(() => appStore.enabledModules.length, () => {
|
||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||
if (activeModule.value !== 'settings' && !enabledIds.includes(activeModule.value)) {
|
||||
const fallback = getFallbackModule()
|
||||
activeModule.value = fallback
|
||||
loadModule(fallback)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 首次加载默认模块
|
||||
loadModule(activeModule.value)
|
||||
appStore.init().catch(e => console.error('App init error:', e))
|
||||
})
|
||||
@@ -78,15 +98,16 @@ onMounted(() => {
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="flex flex-col h-screen w-screen overflow-hidden">
|
||||
<TitleBar :modules="modules" @search="handleSearch" />
|
||||
<TitleBar :modules="availableModules" @search="handleSearch" />
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
:modules="modules"
|
||||
:active-module="activeModule"
|
||||
<Sidebar
|
||||
:modules="availableModules"
|
||||
:active-module="activeModule"
|
||||
@change="handleModuleChange"
|
||||
/>
|
||||
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" />
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-right" rich-colors close-button />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Settings,
|
||||
Globe,
|
||||
ClipboardList,
|
||||
Camera,
|
||||
Activity,
|
||||
Download,
|
||||
Search
|
||||
} from '@lucide/vue'
|
||||
import { Settings } from '@lucide/vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getModuleIcon } from '@/modules/icons'
|
||||
|
||||
const props = defineProps<{
|
||||
modules: Array<{ id: string; name: string; icon: string }>
|
||||
@@ -22,54 +14,45 @@ const emit = defineEmits<{
|
||||
(e: 'change', moduleId: string): void
|
||||
}>()
|
||||
|
||||
const iconMap: Record<string, typeof Settings> = {
|
||||
settings: Settings,
|
||||
proxy: Globe,
|
||||
clipboard: ClipboardList,
|
||||
screenshot: Camera,
|
||||
monitor: Activity,
|
||||
downloader: Download,
|
||||
finder: Search
|
||||
}
|
||||
|
||||
const getIcon = (iconName: string) => {
|
||||
return iconMap[iconName] || Settings
|
||||
}
|
||||
|
||||
const displayModules = computed(() => {
|
||||
return props.modules.filter(m => m.id !== 'settings')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="w-16 flex flex-col items-center py-4 border-r border-border transition-all duration-300"
|
||||
<aside
|
||||
class="w-17 flex flex-col items-center py-4 border-r border-border transition-all duration-300"
|
||||
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
|
||||
>
|
||||
<ButtonGroup orientation="vertical" class="flex flex-col gap-1">
|
||||
<Tooltip v-for="module in displayModules" :key="module.id">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:variant="activeModule === module.id ? 'default' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-10 w-10 rounded-lg transition-all duration-300"
|
||||
:class="{
|
||||
'bg-primary text-primary-foreground shadow-md': activeModule === module.id,
|
||||
'hover:bg-secondary/50': activeModule !== module.id
|
||||
}"
|
||||
@click="emit('change', module.id)"
|
||||
>
|
||||
<component :is="getIcon(module.icon)" class="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="w-fit">
|
||||
<p>{{ module.name }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<TransitionGroup name="module-flip">
|
||||
<div v-for="module in displayModules" :key="module.id">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:variant="activeModule === module.id ? 'default' : 'ghost'"
|
||||
size="icon"
|
||||
class="h-10 w-10 rounded-lg transition-all duration-300"
|
||||
:class="{
|
||||
'bg-primary text-primary-foreground shadow-md': activeModule === module.id,
|
||||
'hover:bg-secondary/50': activeModule !== module.id
|
||||
}"
|
||||
:title="module.name"
|
||||
@click="emit('change', module.id)"
|
||||
>
|
||||
<component :is="getModuleIcon(module.icon)" class="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="w-fit">
|
||||
<p>{{ module.name }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
@@ -80,6 +63,7 @@ const displayModules = computed(() => {
|
||||
'bg-primary text-primary-foreground shadow-md': activeModule === 'settings',
|
||||
'hover:bg-secondary/50': activeModule !== 'settings'
|
||||
}"
|
||||
title="常规设置"
|
||||
@click="emit('change', 'settings')"
|
||||
>
|
||||
<Settings class="h-5 w-5" />
|
||||
@@ -90,4 +74,10 @@ const displayModules = computed(() => {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</aside>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.module-flip-move {
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,18 +49,24 @@ const handleSettingSelect = (item: SearchItem) => {
|
||||
isSearchFocused.value = false
|
||||
}
|
||||
|
||||
const tauriWindow = getCurrentWindow()
|
||||
let tauriWindow: ReturnType<typeof getCurrentWindow> | null = null
|
||||
try {
|
||||
tauriWindow = getCurrentWindow()
|
||||
} catch {
|
||||
// 非 Tauri 环境(如浏览器调试),窗口控制不可用
|
||||
tauriWindow = null
|
||||
}
|
||||
|
||||
const minimize = async () => {
|
||||
await tauriWindow.minimize()
|
||||
await tauriWindow?.minimize()
|
||||
}
|
||||
|
||||
const maximize = async () => {
|
||||
await tauriWindow.toggleMaximize()
|
||||
await tauriWindow?.toggleMaximize()
|
||||
}
|
||||
|
||||
const close = async () => {
|
||||
await tauriWindow.hide()
|
||||
await tauriWindow?.hide()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Thing 日志系统 —— 前端 API
|
||||
*
|
||||
* 用法:
|
||||
* import { createLogger, getLogs, clearLogs, getLogInfo } from '@/lib/logger'
|
||||
*
|
||||
* const logger = createLogger('proxy')
|
||||
* logger.info('mihomo 内核启动成功')
|
||||
* logger.error('连接失败: timeout')
|
||||
*
|
||||
* // 查询日志
|
||||
* const entries = await getLogs('proxy', 'error', 50)
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string
|
||||
level: LogLevel
|
||||
module: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LogInfo {
|
||||
log_dir: string
|
||||
log_files: string[]
|
||||
total_size_bytes: number
|
||||
max_file_size_bytes: number
|
||||
max_files: number
|
||||
}
|
||||
|
||||
// ===== 每个模块一个 Logger 实例 =====
|
||||
|
||||
class Logger {
|
||||
private module: string
|
||||
|
||||
constructor(module: string) {
|
||||
this.module = module
|
||||
}
|
||||
|
||||
private write(level: LogLevel, message: string): void {
|
||||
invoke('log_message', { level, module: this.module, message }).catch(() => {
|
||||
// 日志写入失败不应阻塞业务逻辑
|
||||
})
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
this.write('debug', message)
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.write('info', message)
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
this.write('warn', message)
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.write('error', message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个绑定到指定模块的日志记录器。
|
||||
*
|
||||
* @param module 模块标识,如 'proxy'、'clipboard'、'app' 等
|
||||
*/
|
||||
export function createLogger(module: string): Logger {
|
||||
return new Logger(module)
|
||||
}
|
||||
|
||||
// ===== 静态查询/管理方法 =====
|
||||
|
||||
/**
|
||||
* 查询日志。
|
||||
*
|
||||
* @param module 按模块过滤(可选)
|
||||
* @param level 按级别过滤(可选)
|
||||
* @param limit 返回条数上限(可选,默认全部)
|
||||
*/
|
||||
export async function getLogs(
|
||||
module?: string,
|
||||
level?: LogLevel,
|
||||
limit?: number,
|
||||
): Promise<LogEntry[]> {
|
||||
return invoke('get_logs', { module, level, limit })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有日志文件。
|
||||
*/
|
||||
export async function clearLogs(): Promise<void> {
|
||||
return invoke('clear_logs')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志系统信息(目录、文件列表、空间占用)。
|
||||
*/
|
||||
export async function getLogInfo(): Promise<LogInfo> {
|
||||
return invoke('get_log_info')
|
||||
}
|
||||
+35
-3
@@ -3,6 +3,23 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
// 导入模块注册入口 —— 副作用导入,注册所有模块到 moduleRegistry
|
||||
import './modules'
|
||||
|
||||
import { createLogger } from './lib/logger'
|
||||
const logger = createLogger('main')
|
||||
|
||||
// 全局未捕获异常日志
|
||||
window.addEventListener('error', (event) => {
|
||||
logger.error(`全局JS错误: ${event.message} @ ${event.filename}:${event.lineno}`)
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
logger.error(`未处理的Promise拒绝: ${event.reason}`)
|
||||
})
|
||||
|
||||
logger.info('Thing 应用启动')
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
@@ -10,7 +27,22 @@ app.use(pinia)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 应用挂载后再初始化搜索索引,不阻塞首屏渲染
|
||||
// 应用挂载后初始化搜索索引和进程监听(不阻塞首屏渲染)
|
||||
void import('./stores/searchStore').then(({ useSearchStore }) => {
|
||||
useSearchStore().initGlobalIndex()
|
||||
})
|
||||
const searchStore = useSearchStore()
|
||||
searchStore.initGlobalIndex()
|
||||
|
||||
// 移除已禁用模块的搜索项
|
||||
void import('./stores/appStore').then(({ useAppStore }) => {
|
||||
const appStore = useAppStore()
|
||||
appStore.modules.forEach(m => {
|
||||
if (!m.enabled) {
|
||||
searchStore.unregisterModule(m.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
void import('./stores/processStore').then(({ useProcessStore }) => {
|
||||
useProcessStore().initListener().catch(e => console.error('Process listener init error:', e))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
|
||||
const searchItems: SearchIndexItem[] = [
|
||||
{
|
||||
title: '剪贴板历史',
|
||||
description: '查看和管理剪贴板记录',
|
||||
keywords: ['剪贴板', '复制', '粘贴', 'clipboard', 'copy', 'paste']
|
||||
}
|
||||
]
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'clipboard',
|
||||
name: '剪贴板',
|
||||
icon: 'clipboard',
|
||||
description: '剪贴板历史记录、搜索与多格式预览',
|
||||
category: 'tool',
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./ClipboardModule.vue'),
|
||||
searchItems,
|
||||
order: 20
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
|
||||
const searchItems: SearchIndexItem[] = [
|
||||
{
|
||||
title: '下载管理',
|
||||
description: '管理下载任务',
|
||||
keywords: ['下载', 'download', '文件', 'file']
|
||||
}
|
||||
]
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'downloader',
|
||||
name: '下载器',
|
||||
icon: 'downloader',
|
||||
description: 'HTTP下载、BT/磁力链接支持',
|
||||
category: 'network',
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./DownloaderModule.vue'),
|
||||
searchItems,
|
||||
process: {
|
||||
name: 'aria2c',
|
||||
executable: '',
|
||||
args: ['--enable-rpc', '--rpc-listen-port=6800'],
|
||||
autoStart: false,
|
||||
restartOnCrash: true,
|
||||
maxRestarts: 3
|
||||
},
|
||||
order: 50
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
|
||||
const searchItems: SearchIndexItem[] = [
|
||||
{
|
||||
title: '文件搜索',
|
||||
description: '搜索本地文件',
|
||||
keywords: ['文件', '搜索', 'finder', 'search', 'file']
|
||||
}
|
||||
]
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'finder',
|
||||
name: '文件搜索',
|
||||
icon: 'finder',
|
||||
description: '快速文件搜索、拼音模糊匹配',
|
||||
category: 'tool',
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./FinderModule.vue'),
|
||||
searchItems,
|
||||
order: 60
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Monitor, Sun, Moon, Sparkles, Layers, LogOut } from '@lucide/vue'
|
||||
import { Monitor, Sun, Moon, Sparkles, Layers, LogOut, Package, GripVertical } from '@lucide/vue'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore, type Theme, type EffectType } from '@/stores/appStore'
|
||||
import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/stores/appStore'
|
||||
import { useSearchStore } from '@/stores/searchStore'
|
||||
import { useProcessStore } from '@/stores/processStore'
|
||||
import { getModuleIcon } from '@/modules/icons'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const searchStore = useSearchStore()
|
||||
const processStore = useProcessStore()
|
||||
|
||||
// 始终反映系统真实的深浅色偏好,用于“跟随系统”卡片色块
|
||||
// 始终反映系统真实的深浅色偏好,用于"跟随系统"卡片色块
|
||||
const systemDark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const systemMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleSystemMediaChange = (e: MediaQueryListEvent) => {
|
||||
@@ -82,6 +86,51 @@ const getThemeColor = (themeId: Theme) => {
|
||||
const quitApp = async () => {
|
||||
await invoke('quit_app')
|
||||
}
|
||||
|
||||
/** 判断模块开关是否处于处理中状态 */
|
||||
const isModuleToggling = (moduleId: string): boolean => {
|
||||
return appStore.togglingModules.has(moduleId)
|
||||
}
|
||||
|
||||
/** 获取模块的进程状态文本 */
|
||||
const getProcessStatusText = (moduleId: string): string | null => {
|
||||
const module = appStore.modules.find(m => m.id === moduleId)
|
||||
if (!module?.hasProcess) return null
|
||||
const status = processStore.getProcessStatus(module.id)
|
||||
if (!status) return '未启动'
|
||||
switch (status.status) {
|
||||
case 'running': return '运行中'
|
||||
case 'stopped': return '已停止'
|
||||
case 'crashed': return '已崩溃'
|
||||
case 'starting': return '启动中...'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 模块拖拽排序 =====
|
||||
|
||||
/** 可拖拽的模块列表(仅用户模块,按 moduleOrder 排序)—— 浅拷贝以支持 VueDraggable 原地修改 */
|
||||
const dragList = ref<ModuleInfo[]>(
|
||||
appStore.moduleOrder
|
||||
.map(id => appStore.getModule(id))
|
||||
.filter((m): m is ModuleInfo => !!m && !m.builtin)
|
||||
.map(m => ({ ...m }))
|
||||
)
|
||||
|
||||
/** 监听 store 中模块状态变化,同步 enabled 到本地拖拽列表 */
|
||||
watch(() => appStore.modules, () => {
|
||||
dragList.value.forEach(item => {
|
||||
const storeModule = appStore.getModule(item.id)
|
||||
if (storeModule) {
|
||||
item.enabled = storeModule.enabled
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
/** 拖拽结束时,将新顺序同步到 store */
|
||||
const onDragEnd = () => {
|
||||
appStore.reorderModules(dragList.value.map(m => m.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,9 +149,9 @@ const quitApp = async () => {
|
||||
<Label class="text-base font-medium">开机自启</Label>
|
||||
<p class="text-sm text-muted-foreground">启动 Windows 时自动运行应用</p>
|
||||
</div>
|
||||
<Switch
|
||||
:checked="appStore.isAutoStart"
|
||||
@update:checked="appStore.toggleAutoStart"
|
||||
<Switch
|
||||
:model-value="appStore.isAutoStart"
|
||||
@update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -140,7 +189,7 @@ const quitApp = async () => {
|
||||
<div class="h-10 flex items-center justify-center">
|
||||
<span class="text-sm font-medium">{{ theme.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
<div
|
||||
v-if="appStore.theme === theme.id"
|
||||
class="absolute top-2 right-2 w-5 h-5 bg-primary dark:bg-white rounded-full flex items-center justify-center"
|
||||
>
|
||||
@@ -186,7 +235,7 @@ const quitApp = async () => {
|
||||
<span class="text-sm font-medium">{{ effect.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ effect.description }}</span>
|
||||
</div>
|
||||
<div
|
||||
<div
|
||||
v-if="appStore.effect === effect.id"
|
||||
class="absolute top-2 right-2 w-5 h-5 bg-primary dark:bg-white rounded-full flex items-center justify-center"
|
||||
>
|
||||
@@ -199,6 +248,72 @@ const quitApp = async () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Package class="h-5 w-5 text-primary" />
|
||||
模块管理
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VueDraggable
|
||||
v-model="dragList"
|
||||
:animation="200"
|
||||
:force-fallback="true"
|
||||
handle=".drag-handle"
|
||||
ghost-class="opacity-40"
|
||||
chosen-class="drag-chosen"
|
||||
class="space-y-2"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<div
|
||||
v-for="module in dragList"
|
||||
:key="module.id"
|
||||
class="flex items-center justify-between py-2 px-3 rounded-lg border border-border/50 hover:bg-secondary/30 transition-colors group"
|
||||
:class="{ 'opacity-60': isModuleToggling(module.id) }"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="drag-handle cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
title="拖拽排序"
|
||||
>
|
||||
<GripVertical class="h-4 w-4 no-native-drag" />
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center"
|
||||
:class="module.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
|
||||
>
|
||||
<component :is="getModuleIcon(module.icon)" class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-sm flex items-center gap-2">
|
||||
{{ module.name }}
|
||||
<span
|
||||
v-if="getProcessStatusText(module.id)"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full"
|
||||
:class="module.enabled ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'"
|
||||
>
|
||||
{{ getProcessStatusText(module.id) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ module.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="module.enabled"
|
||||
:disabled="module.builtin || isModuleToggling(module.id)"
|
||||
@update:model-value="(checked: boolean) => appStore.toggleModule(module.id, checked)"
|
||||
/>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
<p class="mt-4 text-xs text-muted-foreground">
|
||||
拖拽手柄可调整模块顺序,禁用模块将从侧边栏隐藏并停止后台进程以减少内存占用。更改后立即生效。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
@@ -207,8 +322,8 @@ const quitApp = async () => {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-destructive border-destructive/20 hover:bg-destructive/10"
|
||||
@click="quitApp"
|
||||
>
|
||||
@@ -219,4 +334,15 @@ const quitApp = async () => {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-native-drag {
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-chosen {
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
import GeneralSettings from './GeneralSettings.vue'
|
||||
|
||||
const searchItems: SearchIndexItem[] = [
|
||||
{
|
||||
title: '浅色模式',
|
||||
description: '切换到浅色主题',
|
||||
keywords: ['浅色', '主题', 'theme', 'light']
|
||||
},
|
||||
{
|
||||
title: '深色模式',
|
||||
description: '切换到深色主题',
|
||||
keywords: ['深色', '主题', 'theme', 'dark']
|
||||
},
|
||||
{
|
||||
title: '跟随系统',
|
||||
description: '跟随系统主题设置',
|
||||
keywords: ['系统', '主题', 'theme', 'system']
|
||||
},
|
||||
{
|
||||
title: '普通模式',
|
||||
description: '标准背景效果',
|
||||
keywords: ['效果', '普通', 'normal', 'effect']
|
||||
},
|
||||
{
|
||||
title: 'Win 云母',
|
||||
description: 'Windows 11 云母效果',
|
||||
keywords: ['效果', '云母', 'mica', 'effect']
|
||||
},
|
||||
{
|
||||
title: 'Win 亚克力',
|
||||
description: 'Windows 11 亚克力效果',
|
||||
keywords: ['效果', '亚克力', 'acrylic', 'effect']
|
||||
},
|
||||
{
|
||||
title: '开机自启',
|
||||
description: '启动 Windows 时自动运行应用',
|
||||
keywords: ['开机', '自启', '自动', 'auto', 'start']
|
||||
}
|
||||
]
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'settings',
|
||||
name: '常规设置',
|
||||
icon: 'settings',
|
||||
description: '主题、效果、开机自启与模块管理',
|
||||
category: 'system',
|
||||
builtin: true,
|
||||
component: GeneralSettings,
|
||||
searchItems,
|
||||
order: 999
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
Settings,
|
||||
Globe,
|
||||
ClipboardList,
|
||||
Camera,
|
||||
Activity,
|
||||
Download,
|
||||
Search
|
||||
} from '@lucide/vue'
|
||||
|
||||
/**
|
||||
* 模块图标映射表
|
||||
*
|
||||
* 模块配置中使用字符串标识(如 'proxy'),通过此表转换为实际图标组件。
|
||||
* 新增模块时,在对应模块的 index.ts 中使用一致的 icon 字符串,
|
||||
* 并在此处添加映射。
|
||||
*/
|
||||
export const moduleIconMap: Record<string, Component> = {
|
||||
settings: Settings,
|
||||
proxy: Globe,
|
||||
clipboard: ClipboardList,
|
||||
screenshot: Camera,
|
||||
monitor: Activity,
|
||||
downloader: Download,
|
||||
finder: Search
|
||||
}
|
||||
|
||||
/** 获取模块图标组件,未找到时回退到 Settings 图标 */
|
||||
export function getModuleIcon(iconName: string): Component {
|
||||
return moduleIconMap[iconName] ?? Settings
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { moduleRegistry } from './registry'
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
|
||||
// 导入所有模块配置 —— 新增模块时在此处添加一行
|
||||
import { moduleConfig as proxy } from './proxy'
|
||||
import { moduleConfig as clipboard } from './clipboard'
|
||||
import { moduleConfig as screenshot } from './screenshot'
|
||||
import { moduleConfig as monitor } from './monitor'
|
||||
import { moduleConfig as downloader } from './downloader'
|
||||
import { moduleConfig as finder } from './finder'
|
||||
import { moduleConfig as general } from './general'
|
||||
|
||||
const allModules: ModuleConfig[] = [
|
||||
proxy,
|
||||
clipboard,
|
||||
screenshot,
|
||||
monitor,
|
||||
downloader,
|
||||
finder,
|
||||
general
|
||||
]
|
||||
|
||||
// 启动时注册所有模块
|
||||
moduleRegistry.registerAll(allModules)
|
||||
|
||||
export { moduleRegistry }
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
|
||||
const searchItems: SearchIndexItem[] = [
|
||||
{
|
||||
title: '硬件监控',
|
||||
description: '查看系统硬件状态',
|
||||
keywords: ['监控', '硬件', 'cpu', '内存', 'monitor', 'hardware']
|
||||
}
|
||||
]
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'monitor',
|
||||
name: '硬件监控',
|
||||
icon: 'monitor',
|
||||
description: 'CPU、GPU、内存实时监控与可视化',
|
||||
category: 'system',
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./MonitorModule.vue'),
|
||||
searchItems,
|
||||
order: 40
|
||||
}
|
||||
@@ -1,24 +1,617 @@
|
||||
<script setup lang="ts">
|
||||
import { Globe } from '@lucide/vue'
|
||||
import {
|
||||
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
|
||||
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
|
||||
Upload, Link2, Loader2
|
||||
} from '@lucide/vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const store = useProxyStore()
|
||||
|
||||
const activeTab = ref('overview')
|
||||
const starting = ref(false)
|
||||
const stopping = ref(false)
|
||||
const restarting = ref(false)
|
||||
const sysProxyLoading = ref(false)
|
||||
const importUrl = ref('')
|
||||
const importName = ref('')
|
||||
const importing = ref(false)
|
||||
const testingGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 进程状态轮询
|
||||
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const running = computed(() => store.status.running)
|
||||
|
||||
// 代理组(Selector/URLTest/Fallback/LoadBalance)
|
||||
const GROUP_TYPES = ['Selector', 'URLTest', 'Fallback', 'LoadBalance']
|
||||
const groups = computed<Array<[string, ProxyNode]>>(() => {
|
||||
return Object.entries(store.proxies).filter(([, n]) => GROUP_TYPES.includes(n.type))
|
||||
})
|
||||
|
||||
const modeOptions = [
|
||||
{ value: 'rule', label: '规则' },
|
||||
{ value: 'global', label: '全局' },
|
||||
{ value: 'direct', label: '直连' }
|
||||
]
|
||||
|
||||
const currentProfile = computed(() =>
|
||||
store.settings?.profiles.find(p => p.id === store.settings?.currentProfile) ?? null
|
||||
)
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
const delayColor = (delay: number | undefined) => {
|
||||
if (!delay) return 'text-muted-foreground'
|
||||
if (delay < 150) return 'text-emerald-500'
|
||||
if (delay < 400) return 'text-amber-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
const delayText = (delay: number | undefined) => {
|
||||
if (delay === undefined) return '—'
|
||||
if (delay === 0) return '超时'
|
||||
return `${delay}ms`
|
||||
}
|
||||
|
||||
// ===== 生命周期 =====
|
||||
const init = async () => {
|
||||
await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()])
|
||||
if (running.value) {
|
||||
store.refreshVersion()
|
||||
store.loadProxies().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
statusTimer = setInterval(async () => {
|
||||
await store.refreshStatus()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (statusTimer) clearInterval(statusTimer)
|
||||
})
|
||||
|
||||
// 状态从停止→运行时,加载节点与版本
|
||||
watch(running, async (val, old) => {
|
||||
if (val && !old) {
|
||||
await store.refreshVersion()
|
||||
await store.loadProxies().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 进程控制 =====
|
||||
const handleStart = async () => {
|
||||
starting.value = true
|
||||
try {
|
||||
await store.start()
|
||||
toast.success('mihomo 已启动')
|
||||
await store.refreshVersion()
|
||||
await store.loadProxies().catch(() => {})
|
||||
} catch (e) {
|
||||
toast.error('启动失败', { description: String(e) })
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
stopping.value = true
|
||||
try {
|
||||
await store.stop()
|
||||
toast.success('mihomo 已停止')
|
||||
} catch (e) {
|
||||
toast.error('停止失败', { description: String(e) })
|
||||
} finally {
|
||||
stopping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestart = async () => {
|
||||
restarting.value = true
|
||||
try {
|
||||
await store.restart()
|
||||
toast.success('mihomo 已重启')
|
||||
await store.refreshVersion()
|
||||
await store.loadProxies().catch(() => {})
|
||||
} catch (e) {
|
||||
toast.error('重启失败', { description: String(e) })
|
||||
} finally {
|
||||
restarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 系统代理 =====
|
||||
const onToggleSystemProxy = async (on: boolean) => {
|
||||
sysProxyLoading.value = true
|
||||
try {
|
||||
await store.toggleSystemProxy(on)
|
||||
toast.success(on ? '系统代理已开启' : '系统代理已关闭')
|
||||
} catch (e) {
|
||||
toast.error('操作失败', { description: String(e) })
|
||||
} finally {
|
||||
sysProxyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 模式切换 =====
|
||||
const changeMode = async (mode: string) => {
|
||||
if (!store.settings || store.settings.mode === mode) return
|
||||
const prev = store.settings.mode
|
||||
store.settings.mode = mode
|
||||
try {
|
||||
await store.saveSettings({ ...store.settings })
|
||||
if (running.value) {
|
||||
await invokePatchConfigs({ mode })
|
||||
}
|
||||
toast.success(`已切换为${modeOptions.find(m => m.value === mode)?.label}模式`)
|
||||
} catch (e) {
|
||||
if (store.settings) store.settings.mode = prev
|
||||
toast.error('模式切换失败', { description: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const invokePatchConfigs = (body: Record<string, unknown>) =>
|
||||
invoke('proxy_patch_configs', { body })
|
||||
|
||||
// ===== 节点 =====
|
||||
const selectNode = async (group: string, name: string) => {
|
||||
// 仅 Selector 允许手动选择
|
||||
if (store.proxies[group]?.type !== 'Selector') return
|
||||
try {
|
||||
await store.selectProxy(group, name)
|
||||
} catch (e) {
|
||||
toast.error('切换节点失败', { description: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const testGroup = async (groupName: string) => {
|
||||
const group = store.proxies[groupName]
|
||||
if (!group?.all?.length) return
|
||||
testingGroups.value.add(groupName)
|
||||
try {
|
||||
await store.testDelayBatch(group.all)
|
||||
toast.success(`「${groupName}」测速完成`)
|
||||
} catch (e) {
|
||||
toast.error('测速失败', { description: String(e) })
|
||||
} finally {
|
||||
testingGroups.value.delete(groupName)
|
||||
}
|
||||
}
|
||||
|
||||
const nodeDelay = (name: string): number | undefined => {
|
||||
return store.proxies[name]?.history?.[0]?.delay
|
||||
}
|
||||
|
||||
// ===== 订阅 =====
|
||||
const doImport = async () => {
|
||||
if (!importUrl.value.trim()) {
|
||||
toast.warning('请输入订阅地址')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const name = importName.value.trim() || `订阅 ${new Date().toLocaleString()}`
|
||||
await store.importProfile(importUrl.value.trim(), name)
|
||||
toast.success('订阅导入成功')
|
||||
importUrl.value = ''
|
||||
importName.value = ''
|
||||
} catch (e) {
|
||||
toast.error('导入失败', { description: String(e) })
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const doUpdate = async (id: string) => {
|
||||
try {
|
||||
await store.updateProfile(id)
|
||||
toast.success('订阅已更新')
|
||||
} catch (e) {
|
||||
toast.error('更新失败', { description: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const doDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除订阅「${name}」?`)) return
|
||||
try {
|
||||
await store.deleteProfile(id)
|
||||
toast.success('已删除订阅')
|
||||
} catch (e) {
|
||||
toast.error('删除失败', { description: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const doActivate = async (id: string) => {
|
||||
try {
|
||||
await store.activateProfile(id)
|
||||
toast.success('已切换订阅,配置已重新生成')
|
||||
if (running.value) {
|
||||
await handleRestart()
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('切换失败', { description: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 设置 =====
|
||||
const localSettings = ref({
|
||||
mixedPort: 7890,
|
||||
externalController: '127.0.0.1:9090',
|
||||
secret: '',
|
||||
logLevel: 'info',
|
||||
allowLan: false,
|
||||
autoStart: false
|
||||
})
|
||||
|
||||
const syncLocalSettings = () => {
|
||||
if (store.settings) {
|
||||
localSettings.value = {
|
||||
mixedPort: store.settings.mixedPort,
|
||||
externalController: store.settings.externalController,
|
||||
secret: store.settings.secret,
|
||||
logLevel: store.settings.logLevel,
|
||||
allowLan: store.settings.allowLan,
|
||||
autoStart: store.settings.autoStart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => store.settings, syncLocalSettings, { immediate: true })
|
||||
|
||||
const savingSettings = ref(false)
|
||||
const saveSettingsForm = async () => {
|
||||
if (!store.settings) return
|
||||
savingSettings.value = true
|
||||
try {
|
||||
await store.saveSettings({
|
||||
...store.settings,
|
||||
...localSettings.value
|
||||
})
|
||||
toast.success('设置已保存')
|
||||
} catch (e) {
|
||||
toast.error('保存失败', { description: String(e) })
|
||||
} finally {
|
||||
savingSettings.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full p-6 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Globe class="h-5 w-5 text-primary" />
|
||||
代理管理模块
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<Globe class="h-16 w-16 mb-4 opacity-50" />
|
||||
<p>代理管理功能开发中...</p>
|
||||
<p class="text-sm mt-2">支持系统代理切换、规则配置、延迟测速等功能</p>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- 概览 -->
|
||||
<TabsContent value="overview" class="flex-1 mt-4 overflow-y-auto">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- 内核状态 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Server class="size-4 text-primary" />内核
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">版本</span>
|
||||
<span class="font-mono text-xs">{{ store.kernel?.version ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-muted-foreground shrink-0">路径</span>
|
||||
<span class="font-mono text-xs text-right break-all">{{ store.kernel?.path ?? '—' }}</span>
|
||||
</div>
|
||||
<p v-if="!store.kernel?.exists" class="text-xs text-amber-600 dark:text-amber-500 leading-relaxed">
|
||||
请将 mihomo.exe 放到 <code class="px-1 bg-muted rounded">src-tauri/binaries/</code> 后重启应用,或直接放到上述 cores 目录。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 运行状态 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Zap class="size-4 text-primary" />运行状态
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">mihomo</span>
|
||||
<span v-if="running" class="flex items-center gap-1 text-emerald-500">
|
||||
<span class="size-2 rounded-full bg-emerald-500" />运行中
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-1 text-muted-foreground">
|
||||
<span class="size-2 rounded-full bg-muted-foreground" />已停止
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">PID</span>
|
||||
<span class="font-mono text-xs">{{ store.status.pid ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">API 版本</span>
|
||||
<span class="font-mono text-xs">{{ store.version || '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">重启次数</span>
|
||||
<span class="font-mono text-xs">{{ store.status.restartCount }}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex gap-2">
|
||||
<Button v-if="!running" size="sm" :disabled="starting" @click="handleStart">
|
||||
<Loader2 v-if="starting" class="size-3.5 animate-spin" />
|
||||
<Play v-else class="size-3.5" />启动
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button size="sm" variant="destructive" :disabled="stopping" @click="handleStop">
|
||||
<Loader2 v-if="stopping" class="size-3.5 animate-spin" />
|
||||
<Square v-else class="size-3.5" />停止
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" :disabled="restarting" @click="handleRestart">
|
||||
<Loader2 v-if="restarting" class="size-3.5 animate-spin" />
|
||||
<RotateCw v-else class="size-3.5" />重启
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 系统代理 -->
|
||||
<Card :class="{ 'opacity-60': sysProxyLoading }">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Power class="size-4 text-primary" />系统代理
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm">Windows 系统代理</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ store.systemProxy ? `指向 127.0.0.1:${store.settings?.mixedPort ?? 7890}` : '已关闭' }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="store.systemProxy"
|
||||
:disabled="sysProxyLoading"
|
||||
@update:model-value="onToggleSystemProxy"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 当前订阅 & 模式 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<ListChecks class="size-4 text-primary" />订阅与模式
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-muted-foreground shrink-0">当前订阅</span>
|
||||
<span class="text-right truncate">{{ currentProfile?.name ?? '无' }}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-muted-foreground">运行模式</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
v-for="m in modeOptions" :key="m.value"
|
||||
size="xs"
|
||||
:variant="store.settings?.mode === m.value ? 'default' : 'outline'"
|
||||
:disabled="!running && store.settings?.mode !== m.value"
|
||||
@click="changeMode(m.value)"
|
||||
>{{ m.label }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 节点 -->
|
||||
<TabsContent value="proxies" class="flex-1 mt-4 min-h-0">
|
||||
<div v-if="!running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<Server class="size-12 opacity-30" />
|
||||
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
|
||||
</div>
|
||||
<ScrollArea v-else class="h-full pr-3">
|
||||
<div v-if="!groups.length" class="text-center text-sm text-muted-foreground py-12">
|
||||
暂无代理组,请先在订阅页导入并激活配置
|
||||
</div>
|
||||
<div class="space-y-4 pb-4">
|
||||
<Card v-for="[gname, group] in groups" :key="gname">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center justify-between text-base">
|
||||
<span class="flex items-center gap-2">
|
||||
<Server class="size-4 text-primary" />{{ gname }}
|
||||
<span class="text-xs font-normal text-muted-foreground">{{ group.type }}</span>
|
||||
</span>
|
||||
<Button
|
||||
size="xs" variant="outline"
|
||||
:disabled="testingGroups.has(gname)"
|
||||
@click="testGroup(gname)"
|
||||
>
|
||||
<Loader2 v-if="testingGroups.has(gname)" class="size-3 animate-spin" />
|
||||
<Zap v-else class="size-3" />测速
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-1.5">
|
||||
<button
|
||||
v-for="node in group.all" :key="node"
|
||||
type="button"
|
||||
class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors hover:bg-accent"
|
||||
:class="group.now === node ? 'border-primary bg-primary/10' : 'border-border'"
|
||||
@click="selectNode(gname, node)"
|
||||
>
|
||||
<span class="truncate text-left">{{ store.proxies[node]?.name ?? node }}</span>
|
||||
<span class="font-mono shrink-0" :class="delayColor(nodeDelay(node))">
|
||||
{{ delayText(nodeDelay(node)) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 订阅 -->
|
||||
<TabsContent value="profiles" class="flex-1 mt-4 overflow-y-auto">
|
||||
<div class="space-y-4 max-w-3xl">
|
||||
<!-- 导入 -->
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Plus class="size-4 text-primary" />导入订阅
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="grid gap-2">
|
||||
<Label for="sub-url">订阅地址</Label>
|
||||
<Input id="sub-url" v-model="importUrl" placeholder="https://example.com/sub.yaml" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="sub-name">名称(可选)</Label>
|
||||
<Input id="sub-name" v-model="importName" placeholder="我的订阅" />
|
||||
</div>
|
||||
<Button size="sm" :disabled="importing" @click="doImport">
|
||||
<Loader2 v-if="importing" class="size-3.5 animate-spin" />
|
||||
<Upload v-else class="size-3.5" />导入
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 列表 -->
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-base">订阅列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="!store.settings?.profiles.length" class="text-center text-sm text-muted-foreground py-8">
|
||||
暂无订阅
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="p in store.settings.profiles" :key="p.id"
|
||||
class="flex items-center gap-3 rounded-md border p-3"
|
||||
:class="store.settings.currentProfile === p.id ? 'border-primary bg-primary/5' : 'border-border'"
|
||||
>
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link2 class="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span class="font-medium text-sm truncate">{{ p.name }}</span>
|
||||
<span v-if="store.settings.currentProfile === p.id" class="text-xs text-primary">当前</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ p.url }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ formatSize(p.size) }} · 更新于 {{ p.updatedAt }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<Button size="icon-sm" variant="ghost" title="更新" @click="doUpdate(p.id)">
|
||||
<RefreshCw class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="store.settings.currentProfile !== p.id"
|
||||
size="icon-sm" variant="ghost" title="切换" @click="doActivate(p.id)"
|
||||
>
|
||||
<Check class="size-3.5" />
|
||||
</Button>
|
||||
<Button size="icon-sm" variant="ghost" title="删除" @click="doDelete(p.id, p.name)">
|
||||
<Trash2 class="size-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 设置 -->
|
||||
<TabsContent value="settings" class="flex-1 mt-4 overflow-y-auto">
|
||||
<Card class="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<SettingsIcon class="size-4 text-primary" />基础设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="mixed-port">混合代理端口</Label>
|
||||
<Input id="mixed-port" v-model.number="localSettings.mixedPort" type="number" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="api-addr">控制接口地址</Label>
|
||||
<Input id="api-addr" v-model="localSettings.externalController" placeholder="127.0.0.1:9090" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="secret">API 密钥(留空则不鉴权)</Label>
|
||||
<Input id="secret" v-model="localSettings.secret" placeholder="可选" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="log-level">日志级别</Label>
|
||||
<Input id="log-level" v-model="localSettings.logLevel" placeholder="info" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p class="text-sm">允许局域网连接</p>
|
||||
<p class="text-xs text-muted-foreground">允许其他设备通过本机代理上网</p>
|
||||
</div>
|
||||
<Switch v-model="localSettings.allowLan" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p class="text-sm">模块启用时自动启动</p>
|
||||
<p class="text-xs text-muted-foreground">在设置中开启代理模块时自动运行 mihomo</p>
|
||||
</div>
|
||||
<Switch v-model="localSettings.autoStart" />
|
||||
</div>
|
||||
<Button size="sm" :disabled="savingSettings" @click="saveSettingsForm">
|
||||
<Loader2 v-if="savingSettings" class="size-3.5 animate-spin" />
|
||||
<Check v-else class="size-3.5" />保存设置
|
||||
</Button>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
修改端口/接口/密钥后需重启 mihomo 生效。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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: ['代理', 'proxy', '网络', 'network', '端口', 'port']
|
||||
},
|
||||
{
|
||||
title: '订阅管理',
|
||||
description: '导入与更新 Clash/mihomo 订阅',
|
||||
keywords: ['订阅', 'subscription', 'profile', '导入']
|
||||
},
|
||||
{
|
||||
title: '节点选择',
|
||||
description: '切换代理节点并测试延迟',
|
||||
keywords: ['节点', 'node', '延迟', 'delay', '测速']
|
||||
},
|
||||
{
|
||||
title: '系统代理',
|
||||
description: '开启或关闭 Windows 系统代理',
|
||||
keywords: ['系统代理', 'system proxy', '开关', 'toggle']
|
||||
}
|
||||
]
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'proxy',
|
||||
name: '代理管理',
|
||||
icon: 'proxy',
|
||||
description: '系统代理切换、订阅管理与延迟测速',
|
||||
category: 'network',
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./ProxyModule.vue'),
|
||||
searchItems,
|
||||
// 进程由 MihomoManager 通过 ProcessManager 统一管理(id='proxy'),
|
||||
// executable/args 在运行时由后端确定,此处仅声明 hasProcess 以便禁用时自动停止。
|
||||
process: {
|
||||
name: 'mihomo',
|
||||
executable: '',
|
||||
autoStart: false,
|
||||
restartOnCrash: true,
|
||||
maxRestarts: 3
|
||||
},
|
||||
lifecycle: {
|
||||
onEnable: async () => {
|
||||
// 若用户在代理设置中开启了"自动启动",则随模块启用而运行 mihomo
|
||||
try {
|
||||
const s = await invoke<{ autoStart?: boolean }>('proxy_get_settings')
|
||||
if (s.autoStart) {
|
||||
await invoke('proxy_start')
|
||||
}
|
||||
} catch {
|
||||
/* 忽略:可能内核未安装 */
|
||||
}
|
||||
},
|
||||
// 禁用模块时一并关闭系统代理,避免代理已停但系统仍指向导致无法上网
|
||||
onDisable: async () => {
|
||||
try {
|
||||
await invoke('proxy_clear_system_proxy')
|
||||
} catch {
|
||||
/* 忽略:可能内核未运行 */
|
||||
}
|
||||
}
|
||||
},
|
||||
order: 10
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Component } from 'vue'
|
||||
import { markRaw, shallowRef } from 'vue'
|
||||
import type { ModuleConfig, ModuleMeta } from '@/types/module'
|
||||
|
||||
/**
|
||||
* 模块注册表 —— 全局单例
|
||||
*
|
||||
* 负责收集、管理所有模块的配置信息,并提供查询接口。
|
||||
* 模块通过 index.ts 导出 ModuleConfig,由 modules/index.ts 统一注册。
|
||||
*/
|
||||
class ModuleRegistry {
|
||||
private configs = new Map<string, ModuleConfig>()
|
||||
private loadedComponents = new Map<string, Component>()
|
||||
|
||||
/** 注册一个模块 */
|
||||
register(config: ModuleConfig): void {
|
||||
if (this.configs.has(config.id)) {
|
||||
console.warn(`[ModuleRegistry] 模块 "${config.id}" 已注册,跳过重复注册`)
|
||||
return
|
||||
}
|
||||
this.configs.set(config.id, config)
|
||||
}
|
||||
|
||||
/** 批量注册 */
|
||||
registerAll(configs: ModuleConfig[]): void {
|
||||
configs.forEach(c => this.register(c))
|
||||
}
|
||||
|
||||
/** 获取模块配置 */
|
||||
getConfig(id: string): ModuleConfig | undefined {
|
||||
return this.configs.get(id)
|
||||
}
|
||||
|
||||
/** 获取所有模块配置 */
|
||||
getAllConfigs(): ModuleConfig[] {
|
||||
return Array.from(this.configs.values()).sort(
|
||||
(a, b) => (a.order ?? 100) - (b.order ?? 100)
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取所有模块的元信息(可序列化) */
|
||||
getAllMetas(): ModuleMeta[] {
|
||||
return this.getAllConfigs().map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
description: c.description,
|
||||
category: c.category,
|
||||
enabled: c.defaultEnabled ?? true,
|
||||
builtin: c.builtin ?? false,
|
||||
hasProcess: !!c.process,
|
||||
order: c.order ?? 100
|
||||
}))
|
||||
}
|
||||
|
||||
/** 获取所有可被用户管理的模块(非内置) */
|
||||
getUserConfigs(): ModuleConfig[] {
|
||||
return this.getAllConfigs().filter(c => !c.builtin)
|
||||
}
|
||||
|
||||
/** 获取内置模块 */
|
||||
getBuiltinConfigs(): ModuleConfig[] {
|
||||
return this.getAllConfigs().filter(c => c.builtin)
|
||||
}
|
||||
|
||||
/** 获取需要进程管理的模块配置 */
|
||||
getProcessConfigs(): ModuleConfig[] {
|
||||
return this.getAllConfigs().filter(c => c.process)
|
||||
}
|
||||
|
||||
/** 获取模块的所有搜索项 */
|
||||
getSearchItems(moduleId: string) {
|
||||
return this.getConfig(moduleId)?.searchItems ?? []
|
||||
}
|
||||
|
||||
/** 收集所有模块的搜索项 */
|
||||
getAllSearchItems(): Array<{ moduleId: string; items: ReturnType<ModuleRegistry['getSearchItems']> }> {
|
||||
return this.getAllConfigs().map(c => ({
|
||||
moduleId: c.id,
|
||||
items: c.searchItems ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
/** 异步加载模块组件,结果会被缓存 */
|
||||
async loadComponent(id: string): Promise<Component | null> {
|
||||
// 缓存命中
|
||||
const cached = this.loadedComponents.get(id)
|
||||
if (cached) return cached
|
||||
|
||||
const config = this.configs.get(id)
|
||||
if (!config) return null
|
||||
|
||||
// 直接组件引用(内置模块)
|
||||
if (config.component) {
|
||||
const raw = markRaw(config.component)
|
||||
this.loadedComponents.set(id, raw)
|
||||
return raw
|
||||
}
|
||||
|
||||
// 懒加载
|
||||
if (config.loader) {
|
||||
try {
|
||||
const mod = await config.loader()
|
||||
const raw = markRaw(mod.default)
|
||||
this.loadedComponents.set(id, raw)
|
||||
return raw
|
||||
} catch (e) {
|
||||
console.error(`[ModuleRegistry] 加载模块 "${id}" 组件失败:`, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** 获取已加载的组件(同步,未加载返回 null) */
|
||||
getLoadedComponent(id: string): Component | null {
|
||||
return this.loadedComponents.get(id) ?? null
|
||||
}
|
||||
|
||||
/** 清除组件缓存 */
|
||||
clearComponentCache(id?: string): void {
|
||||
if (id) {
|
||||
this.loadedComponents.delete(id)
|
||||
} else {
|
||||
this.loadedComponents.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局模块注册表实例 */
|
||||
export const moduleRegistry = new ModuleRegistry()
|
||||
|
||||
/** Vue 组件中使用的响应式引用 */
|
||||
export function useModuleComponent(moduleId: string) {
|
||||
const component = shallowRef<Component | null>(moduleRegistry.getLoadedComponent(moduleId))
|
||||
|
||||
const load = async () => {
|
||||
component.value = await moduleRegistry.loadComponent(moduleId)
|
||||
}
|
||||
|
||||
return { component, load }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ModuleConfig } from '@/types/module'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
|
||||
const searchItems: SearchIndexItem[] = [
|
||||
{
|
||||
title: '截图工具',
|
||||
description: '捕获屏幕截图',
|
||||
keywords: ['截图', '屏幕', 'screenshot', 'capture']
|
||||
}
|
||||
]
|
||||
|
||||
export const moduleConfig: ModuleConfig = {
|
||||
id: 'screenshot',
|
||||
name: '截图',
|
||||
icon: 'screenshot',
|
||||
description: '区域截图、窗口截图与图片编辑',
|
||||
category: 'media',
|
||||
defaultEnabled: true,
|
||||
loader: () => import('./ScreenshotModule.vue'),
|
||||
searchItems,
|
||||
order: 30
|
||||
}
|
||||
+299
-24
@@ -1,48 +1,286 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
|
||||
import { enable, isEnabled, disable } from '@tauri-apps/plugin-autostart'
|
||||
import { moduleRegistry } from '@/modules/registry'
|
||||
import { useSearchStore } from '@/stores/searchStore'
|
||||
import { useProcessStore } from '@/stores/processStore'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { ModuleCategory } from '@/types/module'
|
||||
|
||||
const logger = createLogger('app')
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
export type EffectType = 'normal' | 'mica' | 'acrylic'
|
||||
|
||||
export interface ModuleInfo {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
description: string
|
||||
category: ModuleCategory
|
||||
hasProcess: boolean
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
/** localStorage 版本号 —— 结构变更时递增,自动清除旧数据 */
|
||||
const SETTINGS_VERSION = 4
|
||||
const STORAGE_KEY = 'thing_app_settings'
|
||||
|
||||
/** 从模块注册表初始化模块元信息 */
|
||||
const initModulesFromRegistry = (): ModuleInfo[] => {
|
||||
return moduleRegistry.getAllMetas().map(meta => ({
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
icon: meta.icon,
|
||||
enabled: meta.enabled,
|
||||
description: meta.description,
|
||||
category: meta.category,
|
||||
hasProcess: meta.hasProcess,
|
||||
builtin: meta.builtin
|
||||
}))
|
||||
}
|
||||
|
||||
/** 从注册表初始化模块排序(仅用户模块,按 order 字段排序) */
|
||||
const initModuleOrder = (): string[] => {
|
||||
return moduleRegistry
|
||||
.getAllMetas()
|
||||
.filter(m => !m.builtin)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(m => m.id)
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const theme = ref<Theme>('system')
|
||||
const effect = ref<EffectType>('mica')
|
||||
const isAutoStart = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
|
||||
const moduleOrder = ref<string[]>(initModuleOrder())
|
||||
|
||||
const loadSettings = () => {
|
||||
/** 正在处理切换的模块 ID 集合(防止重复点击) */
|
||||
const togglingModules = ref<Set<string>>(new Set())
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved)
|
||||
|
||||
// 版本不匹配,清除旧数据
|
||||
if (settings.version !== SETTINGS_VERSION) {
|
||||
console.warn('[appStore] Settings version mismatch, clearing old data')
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
saveSettings()
|
||||
return
|
||||
}
|
||||
|
||||
if (settings.theme) theme.value = settings.theme
|
||||
if (settings.effect) effect.value = settings.effect
|
||||
if (settings.isAutoStart !== undefined) isAutoStart.value = settings.isAutoStart
|
||||
if (settings.modules) {
|
||||
const savedModules = settings.modules as Array<{ id: string; enabled: boolean }>
|
||||
savedModules.forEach(sm => {
|
||||
const m = modules.value.find(mod => mod.id === sm.id)
|
||||
if (m) {
|
||||
m.enabled = sm.enabled
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复模块排序:保留已保存的顺序,追加新增模块到末尾
|
||||
if (settings.moduleOrder) {
|
||||
const savedOrder = settings.moduleOrder as string[]
|
||||
const allUserIds = moduleRegistry
|
||||
.getAllMetas()
|
||||
.filter(m => !m.builtin)
|
||||
.map(m => m.id)
|
||||
const known = savedOrder.filter(id => allUserIds.includes(id))
|
||||
const newlyAdded = allUserIds.filter(id => !savedOrder.includes(id))
|
||||
moduleOrder.value = [...known, ...newlyAdded]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to load settings from localStorage')
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
try {
|
||||
const modulesData = modules.value.map(m => ({
|
||||
id: m.id,
|
||||
enabled: m.enabled
|
||||
}))
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
version: SETTINGS_VERSION,
|
||||
theme: theme.value,
|
||||
effect: effect.value,
|
||||
isAutoStart: isAutoStart.value
|
||||
isAutoStart: isAutoStart.value,
|
||||
modules: modulesData,
|
||||
moduleOrder: moduleOrder.value
|
||||
}))
|
||||
} catch {
|
||||
console.error('Failed to save settings to localStorage')
|
||||
}
|
||||
}
|
||||
|
||||
const enabledModules = computed(() => modules.value.filter(m => m.enabled))
|
||||
|
||||
const getModule = (id: string) => modules.value.find(m => m.id === id)
|
||||
|
||||
/** 重新排序模块 */
|
||||
const reorderModules = (newOrder: string[]) => {
|
||||
moduleOrder.value = newOrder
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
const toggleModule = async (moduleId: string, enabled?: boolean) => {
|
||||
const moduleIndex = modules.value.findIndex(m => m.id === moduleId)
|
||||
if (moduleIndex === -1) {
|
||||
console.warn(`[toggleModule] Module "${moduleId}" not found`)
|
||||
return false
|
||||
}
|
||||
|
||||
const moduleInfo = modules.value[moduleIndex]
|
||||
|
||||
// 内置模块不可禁用
|
||||
if (moduleInfo.builtin && enabled === false) {
|
||||
toast.warning('内置模块无法禁用')
|
||||
return false
|
||||
}
|
||||
|
||||
// 防止重复操作
|
||||
if (togglingModules.value.has(moduleId)) {
|
||||
console.log(`[toggleModule] Module "${moduleId}" is already being toggled`)
|
||||
return false
|
||||
}
|
||||
|
||||
const targetState = enabled !== undefined ? enabled : !moduleInfo.enabled
|
||||
if (targetState === moduleInfo.enabled) {
|
||||
console.log(`[toggleModule] Module "${moduleId}" is already ${targetState ? 'enabled' : 'disabled'}`)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log(`[toggleModule] Toggling "${moduleId}" from ${moduleInfo.enabled} to ${targetState}`)
|
||||
togglingModules.value.add(moduleId)
|
||||
|
||||
try {
|
||||
if (!targetState) {
|
||||
// ===== 禁用模块 =====
|
||||
// 1. 先更新状态(让开关立即响应)
|
||||
modules.value[moduleIndex] = { ...moduleInfo, enabled: false }
|
||||
saveSettings()
|
||||
|
||||
// 2. 清理搜索项
|
||||
try {
|
||||
useSearchStore().unregisterModule(moduleId)
|
||||
} catch (e) {
|
||||
console.error(`[toggleModule] Failed to unregister search items for "${moduleId}":`, e)
|
||||
logger.error(`禁用模块 "${moduleId}" 时清理搜索项失败: ${e}`)
|
||||
}
|
||||
|
||||
// 3. 停止进程
|
||||
if (moduleInfo.hasProcess) {
|
||||
try {
|
||||
const processStore = useProcessStore()
|
||||
const status = processStore.getProcessStatus(moduleId)
|
||||
if (status && status.status === 'running') {
|
||||
toast.loading(`正在停止 ${moduleInfo.name} 后台进程...`, { id: `stop-${moduleId}` })
|
||||
await processStore.stopByModule(moduleId)
|
||||
toast.success(`${moduleInfo.name} 进程已停止`, { id: `stop-${moduleId}` })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[toggleModule] Failed to stop process for "${moduleId}":`, e)
|
||||
logger.error(`停止模块 "${moduleId}" 进程失败: ${e}`)
|
||||
toast.error(`停止 ${moduleInfo.name} 进程失败`, { id: `stop-${moduleId}` })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 调用生命周期钩子
|
||||
try {
|
||||
const config = moduleRegistry.getConfig(moduleId)
|
||||
await config?.lifecycle?.onDisable?.()
|
||||
} catch (e) {
|
||||
console.error(`[toggleModule] Module onDisable hook failed for "${moduleId}":`, e)
|
||||
logger.error(`模块 "${moduleId}" onDisable 钩子失败: ${e}`)
|
||||
}
|
||||
|
||||
// 5. 清理组件缓存,释放内存
|
||||
moduleRegistry.clearComponentCache(moduleId)
|
||||
|
||||
logger.info(`已禁用模块: ${moduleInfo.name}`)
|
||||
toast.success(`已禁用 ${moduleInfo.name}`)
|
||||
} else {
|
||||
// ===== 启用模块 =====
|
||||
// 1. 先更新状态(让开关立即响应)
|
||||
modules.value[moduleIndex] = { ...moduleInfo, enabled: true }
|
||||
saveSettings()
|
||||
|
||||
// 2. 调用生命周期钩子
|
||||
try {
|
||||
const config = moduleRegistry.getConfig(moduleId)
|
||||
await config?.lifecycle?.onEnable?.()
|
||||
} catch (e) {
|
||||
console.error(`[toggleModule] Module onEnable hook failed for "${moduleId}":`, e)
|
||||
logger.error(`模块 "${moduleId}" onEnable 钩子失败: ${e}`)
|
||||
}
|
||||
|
||||
// 3. 恢复搜索项
|
||||
try {
|
||||
const searchStore = useSearchStore()
|
||||
const config = moduleRegistry.getConfig(moduleId)
|
||||
if (config?.searchItems) {
|
||||
config.searchItems.forEach((item, index) => {
|
||||
searchStore.registerItem({
|
||||
id: `${moduleId}-search-${index}`,
|
||||
moduleId,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
keywords: item.keywords
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[toggleModule] Failed to register search items for "${moduleId}":`, e)
|
||||
logger.error(`启用模块 "${moduleId}" 时注册搜索项失败: ${e}`)
|
||||
}
|
||||
|
||||
// 4. 如果配置了 autoStart,启动进程
|
||||
if (moduleInfo.hasProcess) {
|
||||
const config = moduleRegistry.getConfig(moduleId)
|
||||
if (config?.process?.autoStart) {
|
||||
try {
|
||||
const processStore = useProcessStore()
|
||||
toast.loading(`正在启动 ${moduleInfo.name} 后台进程...`, { id: `start-${moduleId}` })
|
||||
await processStore.startByModule(moduleId)
|
||||
toast.success(`${moduleInfo.name} 进程已启动`, { id: `start-${moduleId}` })
|
||||
} catch (e) {
|
||||
console.error(`[toggleModule] Failed to start process for "${moduleId}":`, e)
|
||||
logger.error(`启动模块 "${moduleId}" 进程失败: ${e}`)
|
||||
toast.error(`启动 ${moduleInfo.name} 进程失败`, { id: `start-${moduleId}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`已启用模块: ${moduleInfo.name}`)
|
||||
toast.success(`已启用 ${moduleInfo.name}`)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error(`[toggleModule] Failed to toggle module "${moduleId}":`, e)
|
||||
logger.error(`模块 "${moduleId}" 切换失败: ${(e as Error).message}`)
|
||||
toast.error(`操作失败: ${(e as Error).message}`)
|
||||
return false
|
||||
} finally {
|
||||
togglingModules.value.delete(moduleId)
|
||||
}
|
||||
}
|
||||
|
||||
const setTheme = async (newTheme: Theme) => {
|
||||
theme.value = newTheme
|
||||
// (仅系统级主题广播或更换效果时才刷新)。因此亚克力限制为仅"跟随系统"可用。
|
||||
// 切到非系统主题时若当前为亚克力,自动回退到云母,避免深浅色不同步。
|
||||
if (newTheme !== 'system' && effect.value === 'acrylic') {
|
||||
effect.value = 'mica'
|
||||
}
|
||||
@@ -56,9 +294,23 @@ export const useAppStore = defineStore('app', () => {
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
const toggleAutoStart = () => {
|
||||
isAutoStart.value = !isAutoStart.value
|
||||
saveSettings()
|
||||
const toggleAutoStart = async (checked?: boolean) => {
|
||||
const targetState = checked !== undefined ? checked : !isAutoStart.value
|
||||
const previousState = isAutoStart.value
|
||||
|
||||
try {
|
||||
isAutoStart.value = targetState
|
||||
if (targetState) {
|
||||
await enable()
|
||||
} else {
|
||||
await disable()
|
||||
}
|
||||
saveSettings()
|
||||
} catch (e) {
|
||||
isAutoStart.value = previousState
|
||||
console.error('Failed to toggle auto-start:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const applyTheme = async () => {
|
||||
@@ -80,7 +332,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
const tauriWindow = getCurrentWindow()
|
||||
await tauriWindow.setTheme(isDark ? 'dark' : 'light')
|
||||
} catch (e) {
|
||||
console.error('Failed to set window theme:', e)
|
||||
// 非 Tauri 环境下忽略
|
||||
}
|
||||
|
||||
await applyEffect()
|
||||
@@ -95,25 +347,19 @@ export const useAppStore = defineStore('app', () => {
|
||||
const tauriWindow = getCurrentWindow()
|
||||
const isDark = root.classList.contains('dark')
|
||||
|
||||
// 先清除旧效果
|
||||
await tauriWindow.clearEffects()
|
||||
|
||||
if (effect.value === 'normal') {
|
||||
// 普通模式:不使用原生效果,用不透明背景色
|
||||
await tauriWindow.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
|
||||
} else if (effect.value === 'mica') {
|
||||
// 浅色用 micaLight,深色用 micaDark。
|
||||
// 注意:micaDark 仅在系统处于深色模式时才会渲染为深色(Windows 限制)。
|
||||
const micaEffect = (isDark ? 'micaDark' : 'micaLight') as unknown as Effect
|
||||
await tauriWindow.setEffects({
|
||||
effects: [micaEffect],
|
||||
state: EffectState.FollowsWindowActiveState,
|
||||
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0]
|
||||
})
|
||||
// 窗口背景必须透明,原生效果才能显示
|
||||
await tauriWindow.setBackgroundColor('#00000000')
|
||||
} else if (effect.value === 'acrylic') {
|
||||
// Acrylic:亚克力效果,color 使用半透明 RGBA
|
||||
await tauriWindow.setEffects({
|
||||
effects: [Effect.Acrylic],
|
||||
state: EffectState.FollowsWindowActiveState,
|
||||
@@ -122,7 +368,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
await tauriWindow.setBackgroundColor('#00000000')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set window effects:', e)
|
||||
// 非 Tauri 环境下忽略
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +384,8 @@ export const useAppStore = defineStore('app', () => {
|
||||
try {
|
||||
const tauriWindow = getCurrentWindow()
|
||||
await tauriWindow.setTheme(e.matches ? 'dark' : 'light')
|
||||
} catch (err) {
|
||||
console.error('Failed to update window theme on system change:', err)
|
||||
} catch (e) {
|
||||
// 非 Tauri 环境下忽略
|
||||
}
|
||||
|
||||
await applyEffect()
|
||||
@@ -148,8 +394,29 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
loadSettings()
|
||||
// applyTheme 内部已调用 applyEffect,无需重复调用
|
||||
await loadSettings()
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
const savedAutoStart = saved ? JSON.parse(saved).isAutoStart : false
|
||||
const systemAutoStart = await isEnabled()
|
||||
|
||||
isAutoStart.value = systemAutoStart
|
||||
|
||||
if (savedAutoStart !== systemAutoStart) {
|
||||
if (savedAutoStart) {
|
||||
await enable()
|
||||
isAutoStart.value = true
|
||||
} else {
|
||||
await disable()
|
||||
isAutoStart.value = false
|
||||
}
|
||||
saveSettings()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to sync auto-start during init:', e)
|
||||
}
|
||||
|
||||
await applyTheme()
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
@@ -158,9 +425,10 @@ export const useAppStore = defineStore('app', () => {
|
||||
isInitialized.value = true
|
||||
} finally {
|
||||
try {
|
||||
await getCurrentWindow().show()
|
||||
const tauriWindow = getCurrentWindow()
|
||||
await tauriWindow.show()
|
||||
} catch (e) {
|
||||
console.error('Failed to show window:', e)
|
||||
// 非 Tauri 环境下忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +438,13 @@ export const useAppStore = defineStore('app', () => {
|
||||
effect,
|
||||
isAutoStart,
|
||||
isInitialized,
|
||||
modules,
|
||||
moduleOrder,
|
||||
enabledModules,
|
||||
togglingModules,
|
||||
getModule,
|
||||
toggleModule,
|
||||
reorderModules,
|
||||
setTheme,
|
||||
setEffect,
|
||||
toggleAutoStart,
|
||||
@@ -178,4 +453,4 @@ export const useAppStore = defineStore('app', () => {
|
||||
init,
|
||||
loadSettings
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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 { moduleRegistry } from '@/modules/registry'
|
||||
|
||||
/** 进程状态 */
|
||||
export type ProcessStatus = 'running' | 'stopped' | 'crashed' | 'starting'
|
||||
|
||||
/** 进程信息(与 Rust 端 ProcessInfo 对应) */
|
||||
export interface ProcessInfo {
|
||||
id: string
|
||||
name: string
|
||||
status: ProcessStatus
|
||||
pid: number | null
|
||||
restartCount: number
|
||||
}
|
||||
|
||||
/** 启动进程参数(与 Rust 端 StartProcessParams 对应,camelCase) */
|
||||
export interface StartProcessParams {
|
||||
id: string
|
||||
executable: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
name: string
|
||||
restartOnCrash?: boolean
|
||||
maxRestarts?: number
|
||||
}
|
||||
|
||||
export const useProcessStore = defineStore('process', () => {
|
||||
/** 所有已知进程的状态映射(key = 模块 ID) */
|
||||
const processes = ref<Map<string, ProcessInfo>>(new Map())
|
||||
|
||||
let unlistenFn: UnlistenFn | null = null
|
||||
|
||||
/** 启动进程监听,接收 Rust 端的进程状态变更事件 */
|
||||
const initListener = async () => {
|
||||
if (unlistenFn) return
|
||||
unlistenFn = await listen<ProcessInfo>('process-status-changed', (event) => {
|
||||
processes.value.set(event.payload.id, event.payload)
|
||||
})
|
||||
}
|
||||
|
||||
/** 通过模块 ID 启动进程(自动从注册表读取进程配置) */
|
||||
const startByModule = async (moduleId: string): Promise<ProcessInfo> => {
|
||||
const config = moduleRegistry.getConfig(moduleId)
|
||||
if (!config?.process) {
|
||||
throw new Error(`模块 "${moduleId}" 没有进程配置`)
|
||||
}
|
||||
|
||||
const pc = config.process
|
||||
const params: StartProcessParams = {
|
||||
id: moduleId,
|
||||
executable: pc.executable,
|
||||
args: pc.args,
|
||||
cwd: pc.cwd,
|
||||
name: pc.name,
|
||||
restartOnCrash: pc.restartOnCrash,
|
||||
maxRestarts: pc.maxRestarts
|
||||
}
|
||||
|
||||
const info = await invoke<ProcessInfo>('start_process', { params })
|
||||
processes.value.set(moduleId, info)
|
||||
return info
|
||||
}
|
||||
|
||||
/** 通过模块 ID 停止进程 */
|
||||
const stopByModule = async (moduleId: string): Promise<void> => {
|
||||
await invoke('stop_process', { id: moduleId })
|
||||
processes.value.delete(moduleId)
|
||||
}
|
||||
|
||||
/** 获取单个进程状态(从 Rust 端查询最新值) */
|
||||
const refreshStatus = async (moduleId: string): Promise<ProcessInfo | null> => {
|
||||
const info = await invoke<ProcessInfo | null>('get_process_status', { id: moduleId })
|
||||
if (info) {
|
||||
processes.value.set(moduleId, info)
|
||||
} else {
|
||||
processes.value.delete(moduleId)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
/** 刷新所有进程状态 */
|
||||
const refreshAll = async (): Promise<void> => {
|
||||
const all = await invoke<ProcessInfo[]>('get_all_process_status')
|
||||
processes.value.clear()
|
||||
all.forEach((info) => {
|
||||
processes.value.set(info.id, info)
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取进程状态(从本地缓存读取,不触发 Rust 调用) */
|
||||
const getProcessStatus = (moduleId: string): ProcessInfo | null => {
|
||||
return processes.value.get(moduleId) ?? null
|
||||
}
|
||||
|
||||
/** 停止所有进程 */
|
||||
const stopAll = async (): Promise<void> => {
|
||||
await invoke('stop_all_processes')
|
||||
processes.value.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
processes,
|
||||
initListener,
|
||||
startByModule,
|
||||
stopByModule,
|
||||
refreshStatus,
|
||||
refreshAll,
|
||||
getProcessStatus,
|
||||
stopAll
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const logger = createLogger('proxy')
|
||||
|
||||
// ===== 与 Rust 端对应的数据结构(camelCase) =====
|
||||
|
||||
export interface ProxySettings {
|
||||
mixedPort: number
|
||||
externalController: string
|
||||
secret: string
|
||||
mode: string
|
||||
logLevel: string
|
||||
allowLan: boolean
|
||||
systemProxy: boolean
|
||||
autoStart: boolean
|
||||
currentProfile: string | null
|
||||
profiles: ProfileMeta[]
|
||||
}
|
||||
|
||||
export interface ProfileMeta {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
addedAt: string
|
||||
updatedAt: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface KernelInfo {
|
||||
path: string
|
||||
exists: boolean
|
||||
version: string | null
|
||||
}
|
||||
|
||||
export interface ProxyStatus {
|
||||
running: boolean
|
||||
pid: number | null
|
||||
restartCount: number
|
||||
}
|
||||
|
||||
export interface ProxyHistory {
|
||||
time: string
|
||||
delay: number
|
||||
}
|
||||
|
||||
export interface ProxyNode {
|
||||
name: string
|
||||
type: string
|
||||
udp?: boolean
|
||||
all?: string[]
|
||||
now?: string
|
||||
history?: ProxyHistory[]
|
||||
alive?: boolean
|
||||
}
|
||||
|
||||
export interface ProxiesResponse {
|
||||
proxies: Record<string, ProxyNode>
|
||||
}
|
||||
|
||||
export interface MihomoVersion {
|
||||
version: string
|
||||
meta?: boolean
|
||||
}
|
||||
|
||||
export const useProxyStore = defineStore('proxy', () => {
|
||||
const kernel = ref<KernelInfo | null>(null)
|
||||
const status = ref<ProxyStatus>({ running: false, pid: null, restartCount: 0 })
|
||||
const version = ref<string>('')
|
||||
const proxies = ref<Record<string, ProxyNode>>({})
|
||||
const settings = ref<ProxySettings | null>(null)
|
||||
const systemProxy = ref(false)
|
||||
|
||||
/** 内核信息(同时尝试从 resource 提取到 cores/) */
|
||||
const refreshKernel = async () => {
|
||||
try {
|
||||
kernel.value = await invoke<KernelInfo>('proxy_kernel_info')
|
||||
} catch (e) {
|
||||
logger.error('获取内核信息失败: ' + e)
|
||||
}
|
||||
return kernel.value
|
||||
}
|
||||
|
||||
/** 刷新进程状态 */
|
||||
const refreshStatus = async () => {
|
||||
try {
|
||||
status.value = await invoke<ProxyStatus>('proxy_status')
|
||||
} catch (e) {
|
||||
logger.error('获取进程状态失败: ' + e)
|
||||
}
|
||||
return status.value
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
await invoke('proxy_start')
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
await invoke('proxy_stop')
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
await invoke('proxy_restart')
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
/** 获取 mihomo 版本(仅运行时可用) */
|
||||
const refreshVersion = async () => {
|
||||
try {
|
||||
const v = await invoke<MihomoVersion>('proxy_version')
|
||||
version.value = v.version
|
||||
} catch {
|
||||
version.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载节点列表 */
|
||||
const loadProxies = async () => {
|
||||
const res = await invoke<ProxiesResponse>('proxy_get_proxies')
|
||||
proxies.value = res.proxies ?? {}
|
||||
return proxies.value
|
||||
}
|
||||
|
||||
/** 选择节点 */
|
||||
const selectProxy = async (group: string, name: string) => {
|
||||
await invoke('proxy_select_proxy', { group, name })
|
||||
// 更新本地状态
|
||||
if (proxies.value[group]) {
|
||||
proxies.value[group].now = name
|
||||
}
|
||||
}
|
||||
|
||||
/** 测速,返回延迟 ms(失败抛错) */
|
||||
const testDelay = async (name: string): Promise<number> => {
|
||||
return await invoke<number>('proxy_test_delay', { name })
|
||||
}
|
||||
|
||||
/** 批量测速:对一组节点测速,更新 history */
|
||||
const testDelayBatch = async (names: string[]) => {
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
try {
|
||||
const delay = await testDelay(name)
|
||||
const node = proxies.value[name]
|
||||
if (node) {
|
||||
node.history = [{ time: new Date().toISOString(), delay }, ...(node.history ?? [])].slice(0, 5)
|
||||
}
|
||||
} catch {
|
||||
const node = proxies.value[name]
|
||||
if (node) {
|
||||
node.history = [{ time: new Date().toISOString(), delay: 0 }, ...(node.history ?? [])].slice(0, 5)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 设置 ----------
|
||||
const loadSettings = async () => {
|
||||
settings.value = await invoke<ProxySettings>('proxy_get_settings')
|
||||
systemProxy.value = await invoke<boolean>('proxy_get_system_proxy')
|
||||
return settings.value
|
||||
}
|
||||
|
||||
const saveSettings = async (s: ProxySettings) => {
|
||||
await invoke('proxy_save_settings', { settings: s })
|
||||
settings.value = s
|
||||
}
|
||||
|
||||
// ---------- 订阅 ----------
|
||||
const importProfile = async (url: string, name: string) => {
|
||||
const meta = await invoke<ProfileMeta>('proxy_import_profile', { url, name })
|
||||
await loadSettings()
|
||||
return meta
|
||||
}
|
||||
|
||||
const updateProfile = async (id: string) => {
|
||||
const meta = await invoke<ProfileMeta>('proxy_update_profile', { id })
|
||||
await loadSettings()
|
||||
return meta
|
||||
}
|
||||
|
||||
const deleteProfile = async (id: string) => {
|
||||
await invoke('proxy_delete_profile', { id })
|
||||
await loadSettings()
|
||||
}
|
||||
|
||||
const activateProfile = async (id: string) => {
|
||||
await invoke('proxy_activate_profile', { id })
|
||||
await loadSettings()
|
||||
}
|
||||
|
||||
// ---------- 系统代理 ----------
|
||||
const setSystemProxy = async () => {
|
||||
await invoke('proxy_set_system_proxy')
|
||||
systemProxy.value = true
|
||||
if (settings.value) {
|
||||
settings.value.systemProxy = true
|
||||
}
|
||||
}
|
||||
|
||||
const clearSystemProxy = async () => {
|
||||
await invoke('proxy_clear_system_proxy')
|
||||
systemProxy.value = false
|
||||
if (settings.value) {
|
||||
settings.value.systemProxy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换系统代理 */
|
||||
const toggleSystemProxy = async (on: boolean) => {
|
||||
if (on) {
|
||||
await setSystemProxy()
|
||||
} else {
|
||||
await clearSystemProxy()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
kernel,
|
||||
status,
|
||||
version,
|
||||
proxies,
|
||||
settings,
|
||||
systemProxy,
|
||||
// kernel & process
|
||||
refreshKernel,
|
||||
refreshStatus,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
refreshVersion,
|
||||
// proxies
|
||||
loadProxies,
|
||||
selectProxy,
|
||||
testDelay,
|
||||
testDelayBatch,
|
||||
// settings
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
// profiles
|
||||
importProfile,
|
||||
updateProfile,
|
||||
deleteProfile,
|
||||
activateProfile,
|
||||
// system proxy
|
||||
setSystemProxy,
|
||||
clearSystemProxy,
|
||||
toggleSystemProxy
|
||||
}
|
||||
})
|
||||
@@ -8,106 +8,3 @@ export interface SearchIndexConfig {
|
||||
moduleId: string
|
||||
items: SearchIndexItem[]
|
||||
}
|
||||
|
||||
export const searchIndex: SearchIndexConfig[] = [
|
||||
{
|
||||
moduleId: 'settings',
|
||||
items: [
|
||||
{
|
||||
title: '浅色模式',
|
||||
description: '切换到浅色主题',
|
||||
keywords: ['浅色', '主题', 'theme', 'light']
|
||||
},
|
||||
{
|
||||
title: '深色模式',
|
||||
description: '切换到深色主题',
|
||||
keywords: ['深色', '主题', 'theme', 'dark']
|
||||
},
|
||||
{
|
||||
title: '跟随系统',
|
||||
description: '跟随系统主题设置',
|
||||
keywords: ['系统', '主题', 'theme', 'system']
|
||||
},
|
||||
{
|
||||
title: '普通模式',
|
||||
description: '标准背景效果',
|
||||
keywords: ['效果', '普通', 'normal', 'effect']
|
||||
},
|
||||
{
|
||||
title: 'Win 云母',
|
||||
description: 'Windows 11 云母效果',
|
||||
keywords: ['效果', '云母', 'mica', 'effect']
|
||||
},
|
||||
{
|
||||
title: 'Win 亚克力',
|
||||
description: 'Windows 11 亚克力效果',
|
||||
keywords: ['效果', '亚克力', 'acrylic', 'effect']
|
||||
},
|
||||
{
|
||||
title: '开机自启',
|
||||
description: '启动 Windows 时自动运行应用',
|
||||
keywords: ['开机', '自启', '自动', 'auto', 'start']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
moduleId: 'proxy',
|
||||
items: [
|
||||
{
|
||||
title: '代理设置',
|
||||
description: '配置网络代理',
|
||||
keywords: ['代理', 'proxy', '网络', 'network']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
moduleId: 'clipboard',
|
||||
items: [
|
||||
{
|
||||
title: '剪贴板历史',
|
||||
description: '查看和管理剪贴板记录',
|
||||
keywords: ['剪贴板', '复制', '粘贴', 'clipboard', 'copy', 'paste']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
moduleId: 'screenshot',
|
||||
items: [
|
||||
{
|
||||
title: '截图工具',
|
||||
description: '捕获屏幕截图',
|
||||
keywords: ['截图', '屏幕', 'screenshot', 'capture']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
moduleId: 'monitor',
|
||||
items: [
|
||||
{
|
||||
title: '硬件监控',
|
||||
description: '查看系统硬件状态',
|
||||
keywords: ['监控', '硬件', 'cpu', '内存', 'monitor', 'hardware']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
moduleId: 'downloader',
|
||||
items: [
|
||||
{
|
||||
title: '下载管理',
|
||||
description: '管理下载任务',
|
||||
keywords: ['下载', 'download', '文件', 'file']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
moduleId: 'finder',
|
||||
items: [
|
||||
{
|
||||
title: '文件搜索',
|
||||
description: '搜索本地文件',
|
||||
keywords: ['文件', '搜索', 'finder', 'search', 'file']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { searchIndex } from './searchIndex'
|
||||
import { moduleRegistry } from '@/modules/registry'
|
||||
|
||||
export interface SearchItem {
|
||||
id: string
|
||||
@@ -14,12 +14,19 @@ export interface SearchItem {
|
||||
export const useSearchStore = defineStore('search', () => {
|
||||
const items = ref<SearchItem[]>([])
|
||||
|
||||
/**
|
||||
* 初始化全局搜索索引
|
||||
* 从模块注册表收集所有模块的搜索项并注册。
|
||||
* 注意:被禁用的模块搜索项也会被注册,但 appStore.toggleModule
|
||||
* 在禁用时会调用 unregisterModule 移除,启用时会调用 registerItem 恢复。
|
||||
*/
|
||||
const initGlobalIndex = () => {
|
||||
searchIndex.forEach(config => {
|
||||
config.items.forEach((item, index) => {
|
||||
const allSearchItems = moduleRegistry.getAllSearchItems()
|
||||
allSearchItems.forEach(({ moduleId, items: moduleItems }) => {
|
||||
moduleItems.forEach((item, index) => {
|
||||
const searchItem: SearchItem = {
|
||||
id: `${config.moduleId}-search-${index}`,
|
||||
moduleId: config.moduleId,
|
||||
id: `${moduleId}-search-${index}`,
|
||||
moduleId,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
keywords: item.keywords
|
||||
@@ -62,6 +69,11 @@ export const useSearchStore = defineStore('search', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除指定模块的所有搜索项 */
|
||||
const unregisterModule = (moduleId: string) => {
|
||||
items.value = items.value.filter(i => i.moduleId !== moduleId)
|
||||
}
|
||||
|
||||
const search = (query: string) => {
|
||||
if (!query.trim()) return []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
@@ -86,7 +98,8 @@ export const useSearchStore = defineStore('search', () => {
|
||||
registerItem,
|
||||
registerItems,
|
||||
unregisterItem,
|
||||
unregisterModule,
|
||||
search,
|
||||
getItemsByModule
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||
|
||||
/** 模块分类 */
|
||||
export type ModuleCategory = 'network' | 'tool' | 'system' | 'media'
|
||||
|
||||
/** 模块进程配置 —— 需要管理外部子进程的模块填写 */
|
||||
export interface ModuleProcessConfig {
|
||||
/** 进程标识符(如 'mihomo'、'aria2') */
|
||||
name: string
|
||||
/** 可执行文件路径(运行时由模块自行确定) */
|
||||
executable: string
|
||||
/** 启动参数 */
|
||||
args?: string[]
|
||||
/** 工作目录 */
|
||||
cwd?: string
|
||||
/** 模块启用时是否自动启动进程 */
|
||||
autoStart?: boolean
|
||||
/** 进程崩溃后是否自动重启 */
|
||||
restartOnCrash?: boolean
|
||||
/** 最大重启次数(0 = 不限制) */
|
||||
maxRestarts?: number
|
||||
}
|
||||
|
||||
/** 模块生命周期钩子 */
|
||||
export interface ModuleLifecycle {
|
||||
/** 模块首次加载时调用 */
|
||||
onInit?: () => void | Promise<void>
|
||||
/** 模块组件挂载时调用 */
|
||||
onActivate?: () => void | Promise<void>
|
||||
/** 模块组件卸载时调用 */
|
||||
onDeactivate?: () => void | Promise<void>
|
||||
/** 模块被禁用时调用 */
|
||||
onDisable?: () => void | Promise<void>
|
||||
/** 模块被启用时调用 */
|
||||
onEnable?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/** 模块配置 —— 每个模块通过 index.ts 导出此结构 */
|
||||
export interface ModuleConfig {
|
||||
/** 模块唯一标识 */
|
||||
id: string
|
||||
/** 显示名称 */
|
||||
name: string
|
||||
/** 图标标识(对应 Sidebar / Settings 的 iconMap key) */
|
||||
icon: string
|
||||
/** 模块描述(显示在设置界面的模块管理中) */
|
||||
description: string
|
||||
/** 模块分类 */
|
||||
category: ModuleCategory
|
||||
/** 模块是否默认启用 */
|
||||
defaultEnabled?: boolean
|
||||
/** 是否为内置模块(不可禁用,如设置模块) */
|
||||
builtin?: boolean
|
||||
/** 懒加载组件的 loader 函数 */
|
||||
loader?: () => Promise<{ default: Component }>
|
||||
/** 直接组件引用(内置模块可用) */
|
||||
component?: Component
|
||||
/** 全局搜索项 */
|
||||
searchItems?: SearchIndexItem[]
|
||||
/** 进程配置(需要管理子进程的模块填写) */
|
||||
process?: ModuleProcessConfig
|
||||
/** 生命周期钩子 */
|
||||
lifecycle?: ModuleLifecycle
|
||||
/** 排序权重(数值越小越靠前) */
|
||||
order?: number
|
||||
}
|
||||
|
||||
/** 运行时模块元信息(去除了组件等不可序列化字段) */
|
||||
export interface ModuleMeta {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
category: ModuleCategory
|
||||
enabled: boolean
|
||||
builtin: boolean
|
||||
hasProcess: boolean
|
||||
order: number
|
||||
}
|
||||
Reference in New Issue
Block a user