715 lines
28 KiB
Vue
715 lines
28 KiB
Vue
<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>
|