代理修改
This commit is contained in:
@@ -24,6 +24,7 @@ defineProps<{
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
key="empty"
|
||||
class="h-full w-full flex items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<p>未找到模块</p>
|
||||
|
||||
@@ -65,10 +65,43 @@ const maximize = async () => {
|
||||
await tauriWindow?.toggleMaximize()
|
||||
}
|
||||
|
||||
// hover 抑制标志:窗口隐藏时设为 true,重新显示后短暂保持 true 再恢复
|
||||
// 期间用 CSS 覆盖 :hover 样式,避免按钮残留高亮(webview 隐藏时 mouseleave 不触发)
|
||||
const hoverSuppressed = ref(true)
|
||||
|
||||
const close = async () => {
|
||||
// 隐藏前立即抑制 hover,避免冻结的 :hover 状态残留到下次显示
|
||||
hoverSuppressed.value = true
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
await tauriWindow?.hide()
|
||||
}
|
||||
|
||||
// 窗口重新获得焦点时,先保持抑制(防止冻结的 hover 显示),
|
||||
// 短暂延迟后恢复 hover(让真实鼠标位置重新接管)
|
||||
if (tauriWindow) {
|
||||
tauriWindow.onFocusChanged(({ payload: focused }) => {
|
||||
if (focused) {
|
||||
hoverSuppressed.value = true
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
// 100ms 后恢复 hover,足够让浏览器重置伪类状态
|
||||
setTimeout(() => {
|
||||
hoverSuppressed.value = false
|
||||
}, 100)
|
||||
} else {
|
||||
hoverSuppressed.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始时窗口已显示,恢复 hover
|
||||
setTimeout(() => {
|
||||
hoverSuppressed.value = false
|
||||
}, 200)
|
||||
|
||||
const handleBlur = () => {
|
||||
setTimeout(() => {
|
||||
isSearchFocused.value = false
|
||||
@@ -147,22 +180,22 @@ const handleBlur = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pointer-events-auto">
|
||||
<button
|
||||
<div class="flex items-center pointer-events-auto" :class="{ 'hover-suppressed': hoverSuppressed }">
|
||||
<button
|
||||
class="h-10 w-10 flex items-center justify-center hover:bg-secondary/50 transition-colors rounded-sm"
|
||||
@click="minimize"
|
||||
@mousedown.stop
|
||||
>
|
||||
<Minus class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="h-10 w-10 flex items-center justify-center hover:bg-secondary/50 transition-colors rounded-sm"
|
||||
@click="maximize"
|
||||
@mousedown.stop
|
||||
>
|
||||
<Square class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
class="h-10 w-10 flex items-center justify-center hover:bg-destructive/20 transition-colors rounded-sm text-destructive"
|
||||
@click="close"
|
||||
@mousedown.stop
|
||||
@@ -171,4 +204,11 @@ const handleBlur = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 窗口隐藏/重新显示瞬间,抑制按钮 hover 样式,避免冻结的 :hover 残留 */
|
||||
.hover-suppressed button:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProgressRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ProgressIndicator,
|
||||
ProgressRoot,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
modelValue: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProgressRoot
|
||||
data-slot="progress"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ProgressIndicator
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary h-full w-full flex-1 transition-all"
|
||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Progress } from "./Progress.vue"
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { createLogger } from '@/lib/logger'
|
||||
|
||||
const logger = createLogger('proxy')
|
||||
@@ -23,6 +24,18 @@ export interface ProxySettings {
|
||||
autoSwitchInterval: number
|
||||
autoSwitchGroup: string
|
||||
autoSwitchRegion: string
|
||||
/** 内核下载镜像源前缀列表(空串=直连 GitHub) */
|
||||
kernelMirrors: string[]
|
||||
}
|
||||
|
||||
/** 内核安装进度事件载荷,对应 Rust 端 InstallProgress */
|
||||
export interface InstallProgress {
|
||||
/** downloading | extracting | replacing | done | error */
|
||||
stage: string
|
||||
percent: number
|
||||
downloadedBytes: number
|
||||
totalBytes: number | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ProfileMeta {
|
||||
@@ -85,6 +98,11 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
const settings = ref<ProxySettings | null>(null)
|
||||
const systemProxy = ref(false)
|
||||
|
||||
// ===== 内核安装进度 =====
|
||||
const installing = ref(false)
|
||||
const installProgress = ref<InstallProgress | null>(null)
|
||||
let progressUnlisten: UnlistenFn | null = null
|
||||
|
||||
/** 内核信息(同时尝试从 resource 提取到 cores/) */
|
||||
const refreshKernel = async () => {
|
||||
try {
|
||||
@@ -246,16 +264,59 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 内核更新 ----------
|
||||
// ---------- 内核更新 / 安装 ----------
|
||||
const checkKernelUpdate = async (): Promise<KernelUpdateInfo> => {
|
||||
return await invoke<KernelUpdateInfo>('proxy_check_kernel_update')
|
||||
}
|
||||
|
||||
const updateKernel = async () => {
|
||||
await invoke('proxy_update_kernel')
|
||||
const updateKernel = async (mirrorPrefix: string = '') => {
|
||||
await invoke('proxy_update_kernel', { mirrorPrefix })
|
||||
await refreshKernel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次安装内核:调用后端 install_kernel,监听 kernel-install-progress 事件更新进度
|
||||
* @param mirrorPrefix 镜像源前缀(空串=GitHub 直连)
|
||||
* 完成或出错后自动取消监听并清空进度(由调用方控制何时隐藏 UI)
|
||||
*/
|
||||
const installKernel = async (mirrorPrefix: string = ''): Promise<void> => {
|
||||
if (installing.value) return
|
||||
installing.value = true
|
||||
installProgress.value = {
|
||||
stage: 'downloading',
|
||||
percent: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
message: '准备开始下载...'
|
||||
}
|
||||
// 注册进度事件监听
|
||||
if (!progressUnlisten) {
|
||||
progressUnlisten = await listen<InstallProgress>('kernel-install-progress', (e) => {
|
||||
installProgress.value = e.payload
|
||||
})
|
||||
}
|
||||
try {
|
||||
await invoke('proxy_install_kernel', { mirrorPrefix })
|
||||
await refreshKernel()
|
||||
} catch (e) {
|
||||
// 错误事件已由后端 emit,这里仅记录日志
|
||||
logger.error('内核安装失败: ' + e)
|
||||
throw e
|
||||
} finally {
|
||||
// 保留 installProgress 一段时间供 UI 显示终态,由调用方负责清空
|
||||
installing.value = false
|
||||
if (progressUnlisten) {
|
||||
progressUnlisten()
|
||||
progressUnlisten = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空进度状态(UI 在动画结束后调用) */
|
||||
const clearInstallProgress = () => {
|
||||
installProgress.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
kernel,
|
||||
@@ -264,6 +325,8 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
proxies,
|
||||
settings,
|
||||
systemProxy,
|
||||
installing,
|
||||
installProgress,
|
||||
// kernel & process
|
||||
refreshKernel,
|
||||
refreshStatus,
|
||||
@@ -289,8 +352,10 @@ export const useProxyStore = defineStore('proxy', () => {
|
||||
setSystemProxy,
|
||||
clearSystemProxy,
|
||||
toggleSystemProxy,
|
||||
// kernel update
|
||||
// kernel update / install
|
||||
checkKernelUpdate,
|
||||
updateKernel
|
||||
updateKernel,
|
||||
installKernel,
|
||||
clearInstallProgress
|
||||
}
|
||||
})
|
||||
|
||||
@@ -172,4 +172,14 @@
|
||||
/* 确保 Sonner toast 始终在最上层 */
|
||||
[data-sonner-toaster] {
|
||||
z-index: 99999 !important;
|
||||
}
|
||||
|
||||
/* 进度条 indeterminate 动画(无 Content-Length 时使用) */
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
left: -33%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user