主界面修改及代理模块初始化

This commit is contained in:
zhongluofeng
2026-07-15 18:22:07 +08:00
parent 29a5f456cb
commit fb361aff9a
39 changed files with 4768 additions and 396 deletions
+22
View File
@@ -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
}
+30
View File
@@ -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
}
+22
View File
@@ -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
}
+138 -12
View File
@@ -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>
+53
View File
@@ -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
}
+32
View File
@@ -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
}
+26
View File
@@ -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 }
+22
View File
@@ -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
}
+610 -17
View File
@@ -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>
+68
View File
@@ -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
}
+143
View File
@@ -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 }
}
+22
View File
@@ -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
}