代理修改

This commit is contained in:
2026-07-18 17:46:21 +08:00
parent 2d9a8ddef6
commit e84958e0fc
71 changed files with 1221 additions and 134 deletions
+221 -36
View File
@@ -2,7 +2,7 @@
import {
Globe, Play, Square, RotateCw, Power, Zap, Plus, RefreshCw, Trash2,
Check, AlertCircle, Server, Settings as SettingsIcon, ListChecks,
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy
Upload, Link2, Loader2, Download, Timer, Target, FolderOpen, Copy, DownloadCloud
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
@@ -21,9 +21,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select'
import { Progress } from '@/components/ui/progress'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
@@ -573,6 +572,97 @@ const handleUpdateKernel = async () => {
}
}
// ===== 首次安装内核 =====
const installStageText = computed(() => {
const stage = store.installProgress?.stage
switch (stage) {
case 'downloading': return '正在下载'
case 'extracting': return '正在解压'
case 'replacing': return '正在安装'
case 'done': return '安装完成'
case 'error': return '安装失败'
default: return ''
}
})
const installStageColor = computed(() => {
const stage = store.installProgress?.stage
if (stage === 'done') return 'text-emerald-500'
if (stage === 'error') return 'text-destructive'
return 'text-primary'
})
/** 进度条显示百分比:有 totalBytes 时用 percent,否则显示已下载 MB 而不显示百分比 */
const installPercentDisplay = computed(() => {
const p = store.installProgress
if (!p) return 0
// downloading 阶段用 percent(后端按 0-90 计算)
// extracting=92 / replacing=96 / done=100 / error=0
if (p.stage === 'downloading' && !p.totalBytes) {
// 无总长度时,前端不可知百分比,进度条用 indeterminate 动画
return 0
}
return p.percent
})
const installHasTotal = computed(() => store.installProgress?.totalBytes != null)
const formatMB = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(2)} MB`
// ===== 首次安装内核 =====
// 镜像源选择:'__direct' = GitHub 直连(value 不能用空串,reka-ui SelectItem 禁止空 value
// | 'ghproxy.net' 等预设 key | '__custom' = 自定义
const MIRROR_PRESETS = [
{ label: 'GitHub 直连', value: '__direct', hint: '需能访问 GitHub,速度最快' },
{ label: 'ghproxy.net', value: 'https://ghproxy.net/', hint: '老牌公益镜像' },
{ label: 'gh-proxy.com', value: 'https://gh-proxy.com/', hint: '公益镜像' },
{ label: 'ghfast.top', value: 'https://ghfast.top/', hint: '较新镜像' },
{ label: '自定义', value: '__custom', hint: '手动输入镜像站前缀' },
] as const
const mirrorChoice = ref<string>('__direct') // 默认直连
const customMirror = ref<string>('')
const selectedMirrorPrefix = computed(() => {
if (mirrorChoice.value === '__custom') {
// 自定义:保证以 / 结尾,避免拼接错误
const v = customMirror.value.trim()
if (!v) return ''
return v.endsWith('/') ? v : v + '/'
}
// __direct 映射回空串(后端用空串表示直连)
if (mirrorChoice.value === '__direct') return ''
return mirrorChoice.value
})
const handleInstallKernel = async () => {
if (store.installing) return
try {
await store.installKernel(selectedMirrorPrefix.value)
} catch {
// 忽略:watch 已处理 UI 反馈
}
}
// 监听安装进度终态,弹 toast 并延时清空进度
watch(
() => store.installProgress?.stage,
(stage) => {
if (stage === 'done') {
toast.success('内核安装完成', {
description: store.installProgress?.message
})
// 2 秒后清空进度,让用户看到 100% 终态
setTimeout(() => store.clearInstallProgress(), 2000)
} else if (stage === 'error') {
toast.error('内核安装失败', {
description: store.installProgress?.message
})
setTimeout(() => store.clearInstallProgress(), 5000)
}
}
)
// ===== 节点 =====
const refreshProxies = async () => {
await loadProxiesWithError()
@@ -761,7 +851,8 @@ const saveSettingsForm = async () => {
</TabsList>
<!-- 概览 -->
<TabsContent value="overview" class="flex-1 mt-4 overflow-y-auto tab-animate">
<TabsContent value="overview" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="columns-1 md:columns-2 gap-4 [&>*]:mb-4 [&>*]:break-inside-avoid">
<!-- 内核状态 -->
<Card>
@@ -775,7 +866,7 @@ const saveSettingsForm = async () => {
@click="handleCheckUpdate"
>
<Loader2 v-if="checkingUpdate" class="size-3 animate-spin" />
<Download v-else class="size-3" />检查更新
<Download v-else key="icon-download" class="size-3" />检查更新
</Button>
</CardTitle>
</CardHeader>
@@ -789,7 +880,7 @@ const saveSettingsForm = async () => {
<AlertCircle class="size-3.5" />未安装
</span>
</div>
<div class="flex items-center justify-between">
<div v-if="store.kernel?.exists" class="flex items-center justify-between">
<span class="text-muted-foreground">当前版本</span>
<span class="font-mono text-xs" :title="store.kernel?.version ?? ''">{{ versionShort }}</span>
</div>
@@ -799,14 +890,15 @@ const saveSettingsForm = async () => {
{{ kernelUpdateInfo.latestVersion }}
<Button
v-if="kernelUpdateInfo.hasUpdate"
key="btn-update"
size="xs" variant="default"
:disabled="updatingKernel"
@click="handleUpdateKernel"
>
<Loader2 v-if="updatingKernel" class="size-3 animate-spin" />
<Download v-else class="size-3" />更新
<Download v-else key="icon-download" class="size-3" />更新
</Button>
<Check v-else class="size-3 text-emerald-500" />
<Check v-else key="icon-updated" class="size-3 text-emerald-500" />
</span>
</div>
<div class="flex items-center justify-between gap-3">
@@ -828,9 +920,96 @@ const saveSettingsForm = async () => {
</template>
</div>
</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>
<!-- 首次安装区块仅在内核未安装且不在安装中时显示 -->
<div
v-if="!store.kernel?.exists && !store.installProgress"
class="space-y-2 pt-2 border-t"
>
<div class="space-y-1.5">
<Label class="text-xs text-muted-foreground">下载源</Label>
<Select v-model="mirrorChoice">
<SelectTrigger size="sm" class="w-full">
<SelectValue placeholder="选择下载源" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="m in MIRROR_PRESETS"
:key="m.value"
:value="m.value"
>
{{ m.label }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ MIRROR_PRESETS.find(m => m.value === mirrorChoice)?.hint }}
</p>
</div>
<div v-if="mirrorChoice === '__custom'" class="space-y-1.5">
<Label class="text-xs text-muted-foreground">镜像站前缀</Label>
<Input
v-model="customMirror"
placeholder="如 https://ghproxy.net/"
class="h-8 text-xs"
/>
<p class="text-xs text-muted-foreground">
前缀会拼接到 GitHub 下载链接前需以 / 结尾自动补全
</p>
</div>
<Button
size="sm" variant="default" class="w-full"
:disabled="store.installing || (mirrorChoice === '__custom' && !customMirror.trim())"
@click="handleInstallKernel"
>
<DownloadCloud class="size-4" />安装内核
</Button>
</div>
<!-- 安装进度区块 -->
<div
v-if="store.installProgress"
class="rounded-md border p-3 space-y-2 bg-muted/30"
>
<div class="flex items-center justify-between text-xs">
<span :class="installStageColor" class="flex items-center gap-1.5 font-medium">
<Loader2
v-if="['downloading', 'extracting', 'replacing'].includes(store.installProgress.stage)"
key="stage-loading"
class="size-3 animate-spin"
/>
<Check v-else-if="store.installProgress.stage === 'done'" key="stage-done" class="size-3" />
<AlertCircle v-else-if="store.installProgress.stage === 'error'" key="stage-error" class="size-3" />
{{ installStageText }}
</span>
<span v-if="installHasTotal && store.installProgress.stage === 'downloading'" class="font-mono text-muted-foreground">
{{ store.installProgress.percent }}%
</span>
</div>
<Progress
v-if="installHasTotal || store.installProgress.stage !== 'downloading'"
key="progress-bar"
:model-value="installPercentDisplay"
class="h-2"
/>
<div
v-else
key="progress-indeterminate"
class="h-2 w-full overflow-hidden rounded-full bg-primary/20 relative"
>
<div class="absolute inset-y-0 left-0 w-1/3 bg-primary rounded-full animate-[indeterminate_1.2s_ease-in-out_infinite]" />
</div>
<p class="text-xs text-muted-foreground">
<template v-if="store.installProgress.stage === 'downloading'">
{{ store.installProgress.message }}
<span v-if="installHasTotal" class="ml-1">
({{ formatMB(store.installProgress.downloadedBytes) }} / {{ formatMB(store.installProgress.totalBytes!) }})
</span>
<span v-else class="ml-1">{{ formatMB(store.installProgress.downloadedBytes) }}</span>
</template>
<template v-else>{{ store.installProgress.message }}</template>
</p>
</div>
</CardContent>
</Card>
@@ -865,18 +1044,18 @@ const saveSettingsForm = async () => {
</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 v-if="!running" key="btn-start" size="sm" :disabled="starting" @click="handleStart">
<Loader2 v-if="starting" key="starting-loading" class="size-3.5 animate-spin" />
<Play v-else key="starting-icon" class="size-3.5" />启动
</Button>
<template v-else>
<template v-else key="btn-stop-group">
<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" />停止
<Loader2 v-if="stopping" key="stopping-loading" class="size-3.5 animate-spin" />
<Square v-else key="stopping-icon" 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" />重启
<Loader2 v-if="restarting" key="restarting-loading" class="size-3.5 animate-spin" />
<RotateCw v-else key="restarting-icon" class="size-3.5" />重启
</Button>
</template>
</div>
@@ -919,7 +1098,7 @@ const saveSettingsForm = async () => {
</CardTitle>
</CardHeader>
<CardContent class="space-y-3 text-sm">
<template v-if="mainGroupName">
<template v-if="mainGroupName" key="has-main">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">代理组</span>
<span class="font-medium">{{ mainGroupName }}</span>
@@ -947,7 +1126,7 @@ const saveSettingsForm = async () => {
</Select>
</div>
</template>
<div v-else class="text-center text-muted-foreground py-4 text-xs">
<div v-else key="no-main" class="text-center text-muted-foreground py-4 text-xs">
{{ running ? '暂无可选节点,请先导入订阅' : 'mihomo 未运行' }}
</div>
</CardContent>
@@ -1037,28 +1216,30 @@ const saveSettingsForm = async () => {
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<!-- 节点 -->
<TabsContent value="proxies" class="flex-1 mt-4 min-h-0 tab-animate">
<div v-if="!running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2">
<TabsContent value="proxies" class="flex-1 mt-4 tab-animate">
<div v-if="!running" key="not-running" class="h-full flex flex-col items-center justify-center text-muted-foreground gap-2 pt-10 pr-10">
<Server class="size-12 opacity-30" />
<p class="text-sm">mihomo 未运行,请先在概览页启动</p>
</div>
<ScrollArea v-else class="h-full pr-3">
<ScrollArea v-else key="proxy-list" class="h-full pr-3">
<div class="flex items-center justify-between mb-3">
<p v-if="!groups.length" class="text-sm text-muted-foreground">暂无代理组</p>
<p v-else class="text-sm text-muted-foreground">{{ groups.length }} 个代理组</p>
<Button size="xs" variant="outline" :disabled="loadingProxies" @click="refreshProxies">
<Loader2 v-if="loadingProxies" class="size-3 animate-spin" />
<RefreshCw v-else class="size-3" />刷新
<Loader2 v-if="loadingProxies" key="loading-proxies" class="size-3 animate-spin" />
<RefreshCw v-else key="refresh-icon" class="size-3" />刷新
</Button>
</div>
<div v-if="!groups.length" class="text-center text-sm text-muted-foreground py-8">
<div v-if="!groups.length" key="no-groups" class="text-center text-sm text-muted-foreground py-8">
未能加载代理组,请点击刷新重试
</div>
<Accordion
v-else
key="groups-list"
v-model="accordionValue"
type="single"
collapsible
@@ -1081,8 +1262,8 @@ const saveSettingsForm = async () => {
:disabled="testingGroups.has(gname)"
@click.stop="testGroup(gname)"
>
<Loader2 v-if="testingGroups.has(gname)" class="size-3 animate-spin" />
<Zap v-else class="size-3" />测速
<Loader2 v-if="testingGroups.has(gname)" key="testing" class="size-3 animate-spin" />
<Zap v-else key="test-icon" class="size-3" />测速
</Button>
</div>
</div>
@@ -1111,7 +1292,8 @@ const saveSettingsForm = async () => {
</TabsContent>
<!-- 订阅 -->
<TabsContent value="profiles" class="flex-1 mt-4 overflow-y-auto tab-animate">
<TabsContent value="profiles" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<div class="space-y-4 max-w-3xl">
<Card>
<CardHeader class="pb-3">
@@ -1129,8 +1311,8 @@ const saveSettingsForm = async () => {
<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" />导入
<Loader2 v-if="importing" key="importing" class="size-3.5 animate-spin" />
<Upload v-else key="upload-icon" class="size-3.5" />导入
</Button>
</CardContent>
</Card>
@@ -1179,10 +1361,12 @@ const saveSettingsForm = async () => {
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<!-- 设置 -->
<TabsContent value="settings" class="flex-1 mt-4 overflow-y-auto tab-animate">
<TabsContent value="settings" class="flex-1 mt-4 tab-animate">
<ScrollArea class="h-full pr-3">
<Card class="max-w-2xl">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
@@ -1193,8 +1377,8 @@ const saveSettingsForm = async () => {
<!-- 保存设置顶部醒目位置 -->
<div class="flex items-center gap-3 rounded-md border border-primary/30 bg-primary/5 p-3">
<Button size="sm" :disabled="savingSettings" @click="saveSettingsForm">
<Loader2 v-if="savingSettings" class="size-3.5 animate-spin" />
<Check v-else class="size-3.5" />保存设置
<Loader2 v-if="savingSettings" key="saving" class="size-3.5 animate-spin" />
<Check v-else key="saved-icon" class="size-3.5" />保存设置
</Button>
<p class="text-xs text-muted-foreground flex-1">
修改端口/接口/密钥/模式后需重启 mihomo 生效DNS规则等高级配置请直接编辑订阅文件
@@ -1269,6 +1453,7 @@ const saveSettingsForm = async () => {
</div>
</CardContent>
</Card>
</ScrollArea>
</TabsContent>
</Tabs>