代理修改

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
@@ -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>
+45 -5
View File
@@ -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>
+38
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Progress } from "./Progress.vue"
+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>
+70 -5
View File
@@ -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
}
})
+10
View File
@@ -172,4 +172,14 @@
/* 确保 Sonner toast 始终在最上层 */
[data-sonner-toaster] {
z-index: 99999 !important;
}
/* 进度条 indeterminate 动画(无 Content-Length 时使用) */
@keyframes indeterminate {
0% {
left: -33%;
}
100% {
left: 100%;
}
}