Files
Thing/src/modules/settings/GeneralSettings.vue
T

715 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Monitor, Sun, Moon, Sparkles, Layers, LogOut, Package, GripVertical, Info, RefreshCw, Download, Check, Loader2, X } 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 { Progress } from '@/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/stores/appStore'
import { useSearchStore } from '@/stores/searchStore'
import { useProcessStore } from '@/stores/processStore'
import { useDownloaderStore } from '@/stores/downloaderStore'
import { getModuleIcon } from '@/modules/icons'
import { commands, type UpdateCheckResult } from '@/lib/bindings'
import { EVENTS } from '@/lib/constants'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { toast } from 'vue-sonner'
const appStore = useAppStore()
const searchStore = useSearchStore()
const processStore = useProcessStore()
// ===== 关于 / 更新 =====
/** 更新进度事件载荷(与 Rust UpdateProgress 对应) */
interface UpdateProgress {
stage: string
percent: number
downloadedBytes: number
totalBytes: number | null
message: string
}
/** 当前应用版本(启动时读取) */
const currentVersion = ref('')
/** 检查更新的结果 */
const updateResult = ref<UpdateCheckResult | null>(null)
const checking = ref(false)
/** 应用本体更新中 */
const appUpdating = ref(false)
/** ThingHK 内核更新中 */
const kernelUpdating = ref(false)
const progress = ref<UpdateProgress | null>(null)
const thinghkExists = ref(false)
let progressUnlisten: UnlistenFn | null = null
const installTypeText = computed(() =>
updateResult.value?.installType === 'installed' ? '安装版' : '便携版',
)
const loadAppInfo = async () => {
try {
currentVersion.value = await commands.appVersion()
} catch { /* 忽略:后端未就绪 */ }
try {
const info = await invoke('monitor_kernel_info') as { exists?: boolean }
thinghkExists.value = info?.exists ?? false
} catch { /* 忽略:内核未就绪 */ }
}
/** 检查 Gitea 最新 release */
const checkUpdate = async () => {
if (checking.value || appUpdating.value) return
checking.value = true
try {
updateResult.value = await commands.updateCheck()
} catch (e) {
console.error('[updater] 检查更新失败', e)
toast.error('检查更新失败', { description: String(e) })
} finally {
checking.value = false
}
}
// ===== 应用更新:复用下载模块下载安装包(重试/限速/进度事件成熟可靠),
// 下载完成后调用 update_install 执行安装并退出。与代理模块 mihomo 内核更新同模式。 =====
const downloaderStore = useDownloaderStore()
/** 下载中显示取消按钮(下载完成进入 applying 阶段后不可取消) */
const downloadCancellable = ref(false)
/** 取消下载的唤醒回调(由等待 Promise 设置) */
let cancelAppDownload: (() => void) | null = null
/** 格式化速度 MB/s */
const fmtSpeed = (bytesPerSec: number) => `${(bytesPerSec / 1024 / 1024).toFixed(2)} MB/s`
/** 下载并应用应用更新:下载模块下载 → update_install 安装(触发应用退出重启) */
const installUpdate = async () => {
if (appUpdating.value) return
const result = updateResult.value
if (!result) return
// 与后端选择逻辑一致:安装版找 -setup.exe,便携版找非 setup 的 .exe
const asset = result.installType === 'installed'
? result.assets.find(a => a.name.endsWith('-setup.exe'))
: result.assets.find(a => a.name.endsWith('.exe') && !a.name.includes('setup'))
if (!asset) {
toast.error('未找到可用的更新安装包', { description: `安装类型: ${installTypeText.value},请在 release 页手动下载` })
return
}
appUpdating.value = true
progress.value = {
stage: 'downloading',
percent: 0,
downloadedBytes: 0,
totalBytes: asset.size || null,
message: '准备开始下载...'
}
let taskId: string | null = null
let downloadProgressFn: UnlistenFn | null = null
// 用对象持有完成事件解绑函数,避免闭包内赋值导致的 TS 类型收窄问题(同 proxyStore
const completeHolder: { fn: UnlistenFn | null } = { fn: null }
let downloadOk = false
try {
// 确保下载模块事件监听已注册(下载器 UI 与这里共用事件流)
try { await downloaderStore.startEventListeners() } catch { /* 忽略 */ }
taskId = await downloaderStore.addTask(asset.browserDownloadUrl, asset.name, undefined, {}, false)
// 下载进度 → 更新进度条
downloadProgressFn = await listen<{
id: string; completedSize: number; totalSize: number; speed: number; status: string
}>('download-progress', (e) => {
if (e.payload.id !== taskId || !appUpdating.value) return
const pct = e.payload.totalSize > 0
? Math.round((e.payload.completedSize / e.payload.totalSize) * 100)
: 0
progress.value = {
stage: 'downloading',
percent: pct,
downloadedBytes: e.payload.completedSize,
totalBytes: e.payload.totalSize,
message: e.payload.speed > 0
? `正在下载... ${fmtSpeed(e.payload.speed)}`
: '正在下载...'
}
})
// 等待下载完成 / 失败 / 取消
downloadCancellable.value = true
const dlResult = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
let settled = false
const finish = (r: { ok: boolean; error?: string }) => {
if (settled) return
settled = true
cancelAppDownload = null
resolve(r)
}
// 任务添加后瞬间进入终态(如探测即失败)
const initial = downloaderStore.tasks.find(t => t.id === taskId)
if (initial) {
if (initial.status === 'complete') { finish({ ok: true }); return }
if (initial.status === 'error') { finish({ ok: false, error: initial.error || '下载失败' }); return }
}
cancelAppDownload = () => finish({ ok: false, error: '已取消下载' })
listen<{ id: string; status: string; error: string | null }>('download-complete', (e) => {
if (e.payload.id === taskId) {
if (e.payload.status === 'complete') finish({ ok: true })
else finish({ ok: false, error: e.payload.error || '下载失败' })
}
}).then(fn => { completeHolder.fn = fn })
})
downloadCancellable.value = false
if (!dlResult.ok) throw new Error(dlResult.error || '下载失败')
downloadOk = true
// 取下载文件路径 → 移除任务记录(保留文件,安装命令内部会 copy 到临时目录并清理)
const dlTask = downloaderStore.tasks.find(t => t.id === taskId)
if (!dlTask) throw new Error('下载任务未找到')
const exePath = dlTask.dir + '/' + dlTask.filename
try {
await downloaderStore.removeTask(taskId, false)
taskId = null
} catch { /* 任务清理失败不阻断安装 */ }
// 安装阶段(后端 emit applying 进度 → 执行安装 → app.exit(0)
progress.value = {
stage: 'applying',
percent: 100,
downloadedBytes: 0,
totalBytes: null,
message: installTypeText.value === '安装版' ? '正在启动安装程序...' : '正在替换程序文件...'
}
await commands.updateInstall(exePath)
// 成功路径后端已退出应用,不会执行到这里
} catch (e) {
console.error('[updater] 应用更新失败', e)
const msg = String(e)
if (msg.includes('已取消下载')) {
toast.info('已取消更新下载')
} else {
toast.error('应用更新失败', { description: msg })
}
// 清理下载任务:下载失败/取消时删除半成品文件
if (taskId) {
try { await downloaderStore.removeTask(taskId, !downloadOk) } catch { /* 忽略 */ }
}
appUpdating.value = false
progress.value = null
} finally {
downloadCancellable.value = false
cancelAppDownload = null
if (downloadProgressFn) downloadProgressFn()
if (completeHolder.fn) completeHolder.fn()
}
}
/** 取消应用更新下载(仅下载阶段可取消) */
const cancelUpdateDownload = async () => {
if (!downloadCancellable.value) return
cancelAppDownload?.()
}
/** 更新 ThingHK 内核:后端先停止监控内核再覆盖文件 */
const updateThinghkKernel = async () => {
if (kernelUpdating.value) return
kernelUpdating.value = true
try {
await commands.updateThinghk()
await loadAppInfo()
toast.success('ThingHK 内核更新完成')
} catch (e) {
console.error('[updater] ThingHK 更新失败', e)
toast.error('ThingHK 内核更新失败', { description: String(e) })
} finally {
kernelUpdating.value = false
}
}
// 系统真实深浅色偏好,来自 appStore(应用启动时初始化,仅通过 onThemeChanged 更新,
// 不受 setTheme 污染),用于"跟随系统"卡片色块。
const systemDark = computed(() => appStore.systemDark)
onMounted(() => {
loadAppInfo()
// 监听更新进度事件(应用更新与 ThingHK 内核更新共用)
listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
progress.value = e.payload
if (e.payload.stage === 'done') {
kernelUpdating.value = false
progress.value = null
} else if (e.payload.stage === 'error') {
// ThingHK 内核更新失败(后端 emit);应用更新失败走命令 reject 路径
kernelUpdating.value = false
}
}).then((fn) => {
progressUnlisten = fn
})
searchStore.registerAction('settings', 0, () => appStore.setTheme('light'))
searchStore.registerAction('settings', 1, () => appStore.setTheme('dark'))
searchStore.registerAction('settings', 2, () => appStore.setTheme('system'))
searchStore.registerAction('settings', 3, () => appStore.setEffect('normal'))
searchStore.registerAction('settings', 4, () => appStore.setEffect('mica'))
searchStore.registerAction('settings', 5, () => appStore.setEffect('acrylic'))
searchStore.registerAction('settings', 6, () => appStore.toggleAutoStart())
// 新增设置项(模块管理/关于/退出):仅定位滚动到对应卡片
searchStore.registerAction('settings', 7, () => scrollToCard('settings-card-modules'))
searchStore.registerAction('settings', 8, () => scrollToCard('settings-card-about'))
searchStore.registerAction('settings', 9, () => scrollToCard('settings-card-about'))
searchStore.registerAction('settings', 10, () => scrollToCard('settings-card-about'))
searchStore.registerAction('settings', 11, () => scrollToCard('settings-card-quit'))
// 主动刷新所有进程状态,确保内核 badge 显示当前真实状态(而非过期缓存)
processStore.refreshAll().catch(() => { /* 忽略:后端可能未就绪 */ })
})
onUnmounted(() => {
progressUnlisten?.()
progressUnlisten = null
})
const themes: Array<{ id: Theme; name: string; color: string; icon: typeof Sun }> = [
{ id: 'light', name: '浅色模式', color: '#f8fafc', icon: Sun },
{ id: 'dark', name: '深色模式', color: '#1e293b', icon: Moon },
{ id: 'system', name: '跟随系统', color: '#64748b', icon: Monitor }
]
const effects: Array<{ id: EffectType; name: string; color: string; darkColor: string; description: string }> = [
{ id: 'normal', name: '普通模式', color: '#ffffff', darkColor: '#0f172a', description: '标准背景效果' },
{ id: 'mica', name: 'Win 云母', color: '#f1f5f9', darkColor: '#1e293b', description: 'Windows 11 云母效果' },
{ id: 'acrylic', name: 'Win 亚克力', color: '#cbd5e1', darkColor: '#18181b', description: '仅跟随系统主题可用' }
]
// 亚克力仅在"跟随系统"主题下能正确同步深浅色(无深浅枚举,依赖系统级主题广播刷新)
const isAcrylicDisabled = () => appStore.theme !== 'system'
// 应用当前是否为深色模式(响应式,随主题与系统偏好变化)
const isAppDark = computed(() => {
if (appStore.theme === 'dark') return true
if (appStore.theme === 'light') return false
return systemDark.value
})
// 色块阴影:浅色主题用黑色阴影;深色主题黑色阴影不可见,改用微弱亮色高光模拟凸起感
const blockShadow = computed(() =>
isAppDark.value
? '0 4px 10px rgba(255, 255, 255, 0.08)'
: '0 4px 10px rgba(0, 0, 0, 0.2)'
)
// 效果卡片色块预览色:根据当前深浅模式返回对应颜色,尽量接近实际材质效果
const getEffectColor = (effectId: EffectType) => {
const eff = effects.find(e => e.id === effectId)
if (!eff) return '#ffffff'
return isAppDark.value ? eff.darkColor : eff.color
}
const getThemeColor = (themeId: Theme) => {
if (themeId === 'system') {
// 始终使用系统真实的深浅色,而非应用当前主题
return systemDark.value ? '#1e293b' : '#f8fafc'
}
const theme = themes.find(t => t.id === themeId)
return theme?.color || '#f8fafc'
}
const quitApp = async () => {
await invoke('quit_app')
}
/** 滚动到指定卡片(搜索导航定位用)。
* 模块为异步加载,若卡片尚未渲染则短暂重试,直到模块挂载完成。 */
const scrollToCard = (id: string) => {
const tryScroll = (): boolean => {
const el = document.getElementById(id)
if (!el) return false
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
return true
}
if (tryScroll()) return
let attempts = 0
const timer = window.setInterval(() => {
attempts++
if (tryScroll() || attempts >= 20) {
window.clearInterval(timer)
}
}, 100)
}
/** 判断模块开关是否处于处理中状态 */
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>
<div class="h-full p-6 overflow-y-auto">
<div class="max-w-3xl mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Sparkles class="size-5 text-primary" />
常规设置
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between py-2">
<div class="space-y-1">
<Label class="text-base font-medium">开机自启</Label>
<p class="text-sm text-muted-foreground">启动 Windows 时自动运行应用</p>
</div>
<Switch
:model-value="appStore.isAutoStart"
@update:model-value="(checked: boolean) => appStore.toggleAutoStart(checked)"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Layers class="size-5 text-primary" />
主题切换
</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-3 gap-4">
<button
v-for="theme in themes"
:key="theme.id"
class="group relative rounded-lg overflow-hidden border-2 transition-all duration-300 hover:shadow-lg"
:class="appStore.theme === theme.id ? 'border-primary shadow-md' : 'border-border hover:border-primary/50'"
@click="appStore.setTheme(theme.id)"
>
<div
class="h-16 w-full flex items-center justify-center transition-all duration-300"
:style="{
backgroundColor: getThemeColor(theme.id),
boxShadow: blockShadow
}"
>
<component
:is="theme.icon"
class="size-8 transition-colors duration-300"
:class="theme.id === 'dark' || (theme.id === 'system' && systemDark) ? 'text-white' : 'text-gray-800'"
/>
</div>
<div class="h-10 flex items-center justify-center">
<span class="text-sm font-medium">{{ theme.name }}</span>
</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"
>
<svg class="w-3 h-3 text-white dark:text-slate-900" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Sparkles class="size-5 text-primary" />
效果切换
</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-3 gap-4">
<button
v-for="effect in effects"
:key="effect.id"
:disabled="effect.id === 'acrylic' && isAcrylicDisabled()"
class="group relative rounded-lg overflow-hidden border-2 transition-all duration-300"
:class="[
appStore.effect === effect.id ? 'border-primary shadow-md' : 'border-border',
effect.id === 'acrylic' && isAcrylicDisabled()
? 'opacity-50 cursor-not-allowed'
: 'hover:shadow-lg hover:border-primary/50'
]"
@click="appStore.setEffect(effect.id)"
>
<div
class="h-16 w-full transition-all duration-300"
:style="{
backgroundColor: getEffectColor(effect.id),
boxShadow: blockShadow
}"
></div>
<div class="h-14 flex flex-col items-center justify-center p-2">
<span class="text-sm font-medium">{{ effect.name }}</span>
<span class="text-xs text-muted-foreground">{{ effect.description }}</span>
</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"
>
<svg class="w-3 h-3 text-white dark:text-slate-900" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</button>
</div>
</CardContent>
</Card>
<Card id="settings-card-modules">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Package class="size-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">
<Tooltip>
<TooltipTrigger as-child>
<div
class="drag-handle cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
<GripVertical class="size-4 no-native-drag" />
</div>
</TooltipTrigger>
<TooltipContent>拖拽排序</TooltipContent>
</Tooltip>
<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="size-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 id="settings-card-about">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Info class="size-5 text-primary" />
关于
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<!-- 应用版本 + 检查更新 -->
<div class="flex items-center justify-between py-2">
<div class="space-y-1">
<Label class="text-base font-medium">应用版本</Label>
<p class="text-sm text-muted-foreground">
Thing v{{ currentVersion || '…' }}
<span v-if="updateResult" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-muted">
{{ installTypeText }}
</span>
</p>
</div>
<Button
variant="outline"
size="sm"
:disabled="checking || appUpdating"
@click="checkUpdate"
>
<RefreshCw v-if="!checking" class="size-3.5 mr-1.5" />
<Loader2 v-else class="size-3.5 mr-1.5 animate-spin" />
{{ checking ? '检查中...' : '检查更新' }}
</Button>
</div>
<!-- 更新结果 -->
<div v-if="updateResult" class="rounded-lg border border-border/50 p-3 space-y-2">
<div v-if="updateResult.hasUpdate" class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
发现新版本 v{{ updateResult.latestVersion }}
</span>
<span class="text-xs text-muted-foreground">当前 v{{ updateResult.currentVersion }}</span>
</div>
<p
v-if="updateResult.releaseBody"
class="text-xs text-muted-foreground whitespace-pre-wrap max-h-20 overflow-y-auto"
>
{{ updateResult.releaseBody }}
</p>
<div class="flex items-center gap-2">
<Button size="sm" :disabled="appUpdating" @click="installUpdate">
<Download class="size-3.5 mr-1.5" />
{{ appUpdating ? '更新中...' : '下载并更新' }}
</Button>
<span class="text-xs text-muted-foreground">
{{ installTypeText === '安装版' ? '将静默安装新版并重启' : '将替换程序文件并重启' }}
</span>
</div>
</div>
<div v-else class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Check class="size-4 text-green-500" />
已是最新版本
</div>
</div>
<!-- 应用更新进度 -->
<div v-if="appUpdating && progress" class="space-y-1.5 py-1">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ progress.message }}</span>
<div class="flex items-center gap-2">
<span class="font-mono">{{ progress.percent }}%</span>
<Button
v-if="downloadCancellable"
variant="ghost"
size="sm"
class="h-5 px-1.5 text-xs"
@click="cancelUpdateDownload"
>
<X class="size-3 mr-0.5" />
取消
</Button>
</div>
</div>
<Progress :model-value="progress.percent" />
</div>
<!-- ThingHK 内核 -->
<div class="flex items-center justify-between py-2 border-t border-border/50">
<div class="space-y-1">
<Label class="text-base font-medium">ThingHK 内核</Label>
<p class="text-sm text-muted-foreground">
{{ thinghkExists ? '已安装' : '未安装' }} · 更新前请先停用监控模块
</p>
</div>
<Button variant="outline" size="sm" :disabled="kernelUpdating" @click="updateThinghkKernel">
<Loader2 v-if="kernelUpdating && !progress" class="size-3.5 mr-1.5 animate-spin" />
<Package v-else class="size-3.5 mr-1.5" />
{{ kernelUpdating ? '更新中...' : '更新内核' }}
</Button>
</div>
<!-- ThingHK 更新进度 -->
<div v-if="kernelUpdating && progress" class="space-y-1.5 py-1">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ progress.message }}</span>
<span class="font-mono">{{ progress.percent }}%</span>
</div>
<Progress :model-value="progress.percent" />
</div>
</CardContent>
</Card>
<Card id="settings-card-quit">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<LogOut class="size-5 text-destructive" />
退出程序
</CardTitle>
</CardHeader>
<CardContent>
<Button
variant="outline"
class="text-destructive border-destructive/20 hover:bg-destructive/10"
@click="quitApp"
>
<LogOut class="size-4 mr-2" />
彻底退出
</Button>
</CardContent>
</Card>
</div>
</div>
</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>