下载/主界面优化

This commit is contained in:
2026-07-22 22:26:40 +08:00
parent f7d3c13f35
commit 95a69b0017
16 changed files with 295 additions and 106 deletions
+44 -10
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { Search, Minus, Square, X, Settings, ChevronRight } from '@lucide/vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Search, Minus, X, Settings, ChevronRight } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useSearchStore, type SearchItem } from '@/stores/searchStore'
@@ -64,6 +64,10 @@ const minimize = async () => {
await tauriWindow?.minimize()
}
// 窗口最大化状态:切换最大化/还原图标
const isMaximized = ref(false)
let unlistenMaximize: (() => void) | null = null
const maximize = async () => {
await tauriWindow?.toggleMaximize()
}
@@ -119,9 +123,9 @@ if (tauriWindow) {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// 200ms 后恢复 hover,足够让浏览器重置伪类状态
restoreHover(200)
// 同时注册鼠标移动监听作为后备(定时器可能不够,鼠标移动更可靠)
// 不使用定时器恢复 hover:窗口从隐藏恢复显示时,浏览器 :hover 可能被冻结
// mouseleave 在窗口隐藏时不触发),定时器恢复会让冻结的 hover 提前暴露
// 仅依赖鼠标移动恢复——移动时浏览器才会重新评估 :hover 状态
armMouseMoveRestore()
} else {
hoverSuppressed.value = true
@@ -132,9 +136,23 @@ if (tauriWindow) {
// 初始时窗口已显示,恢复 hover
restoreHover(300)
onMounted(async () => {
if (tauriWindow) {
try {
isMaximized.value = await tauriWindow.isMaximized()
unlistenMaximize = await tauriWindow.onResized(async () => {
isMaximized.value = await tauriWindow!.isMaximized()
})
} catch {
// 非 Tauri 环境忽略
}
}
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
if (unlistenMaximize) unlistenMaximize()
})
const handleBlur = () => {
@@ -241,21 +259,30 @@ const handleBlur = () => {
<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"
class="h-10 w-10 flex items-center justify-center hover:bg-foreground/5 transition-colors rounded-sm"
@click="minimize"
@mousedown.stop
>
<Minus class="h-4 w-4" />
</button>
<button
class="h-10 w-10 flex items-center justify-center hover:bg-secondary/50 transition-colors rounded-sm"
id="titlebar-maximize"
class="h-10 w-10 flex items-center justify-center transition-colors rounded-sm titlebar-maximize-btn"
@click="maximize"
@mousedown.stop
>
<Square class="h-3.5 w-3.5" />
<!-- 最大化单圆角矩形 -->
<svg v-if="!isMaximized" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" />
</svg>
<!-- 还原单圆角矩形 + 右上角圆角 L形顶边+右边圆角连接 -->
<svg v-else class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M14 4H20V10" />
</svg>
</button>
<button
class="h-10 w-10 flex items-center justify-center hover:bg-destructive/20 transition-colors rounded-sm text-destructive"
class="h-10 w-10 flex items-center justify-center hover:bg-red-500 hover:text-white text-foreground transition-colors rounded-sm"
@click="close"
@mousedown.stop
>
@@ -267,10 +294,17 @@ const handleBlur = () => {
<style scoped>
/* 窗口隐藏/重新显示瞬间,抑制按钮 hover 样式,避免冻结的 :hover 残留 */
.hover-suppressed button:hover {
.hover-suppressed button:hover,
.hover-suppressed button.is-hovered {
background-color: transparent !important;
}
/* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件,
:hover 无法触发,改用插件注入的 .is-hovered 类(鼠标进入原生子窗口时添加) */
.titlebar-maximize-btn.is-hovered {
background-color: color-mix(in srgb, var(--foreground) 5%, transparent);
}
/* 浮动标签切换器进出动画:淡入 + 从左侧滑入 */
.floating-tabs-enter-active,
.floating-tabs-leave-active {
+4 -4
View File
@@ -640,17 +640,17 @@ const toggleSortOrder = () => {
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="task.status === 'active'"
v-if="task.status === 'active' || task.status === 'queued'"
size="icon"
variant="ghost"
class="h-8 w-8"
title="暂停"
:title="task.status === 'queued' ? '取消排队' : '暂停'"
@click="handlePause(task.id)"
>
<Pause class="h-4 w-4" />
</Button>
<Button
v-if="task.status === 'paused' || task.status === 'queued' || task.status === 'error'"
v-if="task.status === 'paused' || task.status === 'error'"
size="icon"
variant="ghost"
class="h-8 w-8"
@@ -1099,7 +1099,7 @@ const toggleSortOrder = () => {
<Textarea
v-model="addUriText"
placeholder="https://example.com/file.zip&#10;https://example.com/file2.zip"
class="min-h-[120px] font-mono text-sm"
class="min-h-[120px] font-mono text-sm [field-sizing:fixed] break-all"
/>
</div>
+101 -23
View File
@@ -558,26 +558,40 @@ const handleCheckUpdate = async () => {
}
const handleUpdateKernel = async () => {
const ok = await showConfirm({
title: '更新内核',
description: '更新内核需要先停止 mihomo,确认继续?',
confirmText: '继续更新'
})
if (!ok) return
updatingKernel.value = true
try {
// 先停止
if (running.value) {
// 展开更新区块(下载源选择 + 进度),由 handleStartUpdate 执行实际更新
updateExpanded.value = true
}
/** 是否展开"更新内核"区块(下载源 + 进度) */
const updateExpanded = ref(false)
/** 开始更新:停止 mihomo → 调用 updateKernel(复用 installProgress 进度机制) */
const handleStartUpdate = async () => {
if (store.installing) return
// 确认停止 mihomo
if (running.value) {
const ok = await showConfirm({
title: '更新内核',
description: '更新内核需要先停止 mihomo,确认继续?',
confirmText: '继续更新'
})
if (!ok) return
updatingKernel.value = true
try {
await store.stop()
} catch (e) {
toast.error('停止 mihomo 失败', { description: String(e) })
updatingKernel.value = false
return
} finally {
updatingKernel.value = false
}
toast.info('正在下载并安装内核...')
await store.updateKernel()
toast.success('内核更新完成')
kernelUpdateInfo.value = null
}
toast.info('开始下载更新...')
try {
await store.updateKernel(selectedMirrorPrefix.value)
} catch (e) {
toast.error('内核更新失败', { description: String(e) })
} finally {
updatingKernel.value = false
}
}
@@ -653,7 +667,8 @@ const handleInstallKernel = async () => {
}
}
// 监听安装进度终态,弹 toast 并延时清空进度
// 监听安装/更新进度终态,弹 toast 并延时清空进度
// 同时处理更新场景下的 updateExpanded 清理(与 installProgress 同步清除,避免更新区块闪烁)
watch(
() => store.installProgress?.stage,
(stage) => {
@@ -661,13 +676,22 @@ watch(
toast.success('内核安装完成', {
description: store.installProgress?.message
})
// 2 秒后清空进度,让用户看到 100% 终态
setTimeout(() => store.clearInstallProgress(), 2000)
if (updateExpanded.value) {
kernelUpdateInfo.value = null
}
// 2 秒后同时清空进度和更新区块,让用户看到 100% 终态
setTimeout(() => {
store.clearInstallProgress()
updateExpanded.value = false
}, 2000)
} else if (stage === 'error') {
toast.error('内核安装失败', {
description: store.installProgress?.message
})
setTimeout(() => store.clearInstallProgress(), 5000)
setTimeout(() => {
store.clearInstallProgress()
// 错误时不清除 updateExpanded,让用户可以重新选择下载源重试
}, 5000)
}
}
)
@@ -912,11 +936,10 @@ const saveSettingsForm = async () => {
v-if="kernelUpdateInfo.hasUpdate"
key="btn-update"
size="xs" variant="default"
:disabled="updatingKernel"
:disabled="store.installing || updateExpanded"
@click="handleUpdateKernel"
>
<Loader2 v-if="updatingKernel" class="size-3 animate-spin" />
<Download v-else key="icon-download" class="size-3" />更新
<Download key="icon-download" class="size-3" />更新
</Button>
<Check v-else key="icon-updated" class="size-3 text-emerald-500" />
</span>
@@ -941,6 +964,61 @@ const saveSettingsForm = async () => {
</div>
</div>
<!-- 更新内核区块检查到更新且用户点击"更新"后展开复用首次安装的下载源选择 UI -->
<div
v-if="updateExpanded && !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>
<div class="flex gap-2">
<Button
size="sm" variant="outline" class="flex-1"
:disabled="store.installing || updatingKernel"
@click="updateExpanded = false"
>
取消
</Button>
<Button
size="sm" variant="default" class="flex-1"
:disabled="store.installing || updatingKernel || (mirrorChoice === '__custom' && !customMirror.trim())"
@click="handleStartUpdate"
>
<Loader2 v-if="store.installing || updatingKernel" class="size-4 animate-spin" />
<DownloadCloud v-else class="size-4" />开始更新
</Button>
</div>
</div>
<!-- 首次安装区块仅在内核未安装且不在安装中且已初始化时显示 -->
<div
v-if="store.initialized && !store.kernel?.exists && !store.installProgress"
+33 -3
View File
@@ -272,9 +272,39 @@ export const useProxyStore = defineStore('proxy', () => {
return await invoke<KernelUpdateInfo>('proxy_check_kernel_update')
}
const updateKernel = async (mirrorPrefix: string = '') => {
await invoke('proxy_update_kernel', { mirrorPrefix })
await refreshKernel()
/**
* 更新内核:复用 installKernel 的完整进度事件机制(installing/installProgress/事件监听)
* 与 installKernel 的区别仅在于后端会先 stop mihomo(由调用方在前端控制),
* 后端 proxy_update_kernel 与 proxy_install_kernel 共用 install_kernel 实现
*/
const updateKernel = 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_update_kernel', { mirrorPrefix })
await refreshKernel()
} catch (e) {
logger.error('内核更新失败: ' + e)
throw e
} finally {
installing.value = false
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
}
/**
+1 -1
View File
@@ -6,7 +6,7 @@ export type ModuleCategory = 'network' | 'tool' | 'system' | 'media'
/** 模块进程配置 —— 需要管理外部子进程的模块填写 */
export interface ModuleProcessConfig {
/** 进程标识符(如 'mihomo'、'aria2' */
/** 进程标识符(如 'mihomo' */
name: string
/** 可执行文件路径(运行时由模块自行确定) */
executable: string