性能优化

This commit is contained in:
zhongluofeng
2026-08-06 10:33:16 +08:00
parent c7578a2e6b
commit e66c53e66d
105 changed files with 7273 additions and 5002 deletions
+5 -5
View File
@@ -6,7 +6,7 @@ import {
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -249,14 +249,14 @@ const handleClear = async () => {
toast.success('已清空历史')
}
// 显示辅助
const kindIcon = (k: ClipboardKind) => {
// 显示辅助kind 来自 bindings 生成的 string,按字符串比较)
const kindIcon = (k: string) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: string) =>
k === 'text'
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: k === 'image'
+32 -34
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2,
@@ -14,21 +16,9 @@ import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
// ===== 与 Rust 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
interface HistoryPage {
items: ClipboardItem[]
total: number
}
// ===== 与 Rust 端对应的数据结构(bindings 提供,camelCase =====
// kind 为 bindings 生成的 string,前端按字符串比较即可
import type { ClipboardItem, HistoryPage } from '@/lib/bindings'
// ===== 状态 =====
const items = ref<ClipboardItem[]>([])
@@ -45,27 +35,35 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// ===== 数据加载 =====
/** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
let loadSeq = 0
async function loadData() {
const seq = ++loadSeq
loading.value = true
try {
const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset })
res = await commands.clipboardSearch(q, PAGE_SIZE, offset)
} else {
res = await invoke<HistoryPage>('clipboard_get_history', { limit: PAGE_SIZE, offset, kind: 'all' })
res = await commands.clipboardGetHistory(PAGE_SIZE, offset, 'all')
}
if (seq !== loadSeq) return // 过期请求丢弃
items.value = res.items
total.value = res.total
selectedIndex.value = 0
} catch (e) {
if (seq !== loadSeq) return
console.error('[clipboard-popup] 加载失败:', e)
} finally {
loading.value = false
// 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
if (seq === loadSeq) loading.value = false
}
if (seq === loadSeq) {
await nextTick()
scrollSelectedIntoView()
}
await nextTick()
scrollSelectedIntoView()
}
async function gotoPage(p: number) {
@@ -84,9 +82,9 @@ watch(searchQuery, () => {
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
async function selectAndPaste(item: ClipboardItem) {
try {
await invoke('clipboard_copy_back', { id: item.id })
await commands.clipboardCopyBack(item.id)
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
await invoke('clipboard_paste_to_target')
await commands.clipboardPasteToTarget()
} catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e)
// 失败时至少隐藏窗口
@@ -97,7 +95,7 @@ async function selectAndPaste(item: ClipboardItem) {
async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned })
await commands.clipboardSetPinned(item.id, !item.pinned)
await loadData()
} catch (e) {
console.error('[clipboard-popup] 固定失败:', e)
@@ -107,7 +105,7 @@ async function togglePin(item: ClipboardItem, ev: Event) {
async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_delete', { id: item.id })
await commands.clipboardDelete(item.id)
items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) {
console.error('[clipboard-popup] 删除失败:', e)
@@ -116,7 +114,7 @@ async function deleteItem(item: ClipboardItem, ev: Event) {
async function hideWindow() {
try {
await invoke('clipboard_hide_popup')
await commands.clipboardHidePopup()
} catch {
/* 忽略 */
}
@@ -153,14 +151,14 @@ function scrollSelectedIntoView() {
})
}
// ===== 显示辅助 =====
const kindIcon = (k: ClipboardKind) => {
// ===== 显示辅助kind 为 bindings 生成的 string,按字符串比较) =====
const kindIcon = (k: string) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: string) =>
k === 'text'
? 'badge-text'
: k === 'image'
@@ -205,7 +203,7 @@ async function onItemHover(idx: number, item: ClipboardItem) {
try {
let src = imageCache.get(item.id)
if (!src) {
const detail = await invoke<{ imageBase64: string | null } | null>('clipboard_get_item', { id: item.id })
const detail = await commands.clipboardGetItem(item.id)
if (detail?.imageBase64) {
src = buildImageDataUrl(detail.imageBase64)
imageCache.set(item.id, src)
@@ -237,7 +235,7 @@ function onItemLeave() {
/** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return {
@@ -330,7 +328,7 @@ onMounted(async () => {
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据
unlistenFns.push(await listen('clipboard-popup-show', async () => {
unlistenFns.push(await listen(EVENTS.clipboardPopupShow, async () => {
// 主应用可能切换了主题,每次显示前重新应用
await applyTheme()
searchQuery.value = ''
@@ -351,7 +349,7 @@ onMounted(async () => {
// 主题和数据都就绪后,调用 Rust 端显示窗口
try {
await invoke('clipboard_show_window')
await commands.clipboardShowWindow()
} catch {
/* 忽略 */
}
+56 -40
View File
@@ -9,10 +9,11 @@ import {
} from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { invoke } from '@tauri-apps/api/core'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { pendingNewDownload } from '@/lib/trayEvents'
@@ -218,13 +219,21 @@ const onRemoveOpenChange = (open: boolean) => {
}
// ===== 任务详情弹窗 =====
const detailDialogState = ref<{ open: boolean; task: DownloadTask | null }>({
// 仅存任务 id,通过 computed 实时从 store.tasks 取最新对象,
// 保证弹窗内的进度/速度/状态随下载进度事件实时刷新
const detailDialogState = ref<{ open: boolean; taskId: string | null }>({
open: false,
task: null
taskId: null
})
const detailTask = computed<DownloadTask | null>(() => {
const id = detailDialogState.value.taskId
if (!id) return null
return store.tasks.find((t) => t.id === id) ?? null
})
const handleShowDetail = (task: DownloadTask) => {
detailDialogState.value = { open: true, task }
detailDialogState.value = { open: true, taskId: task.id }
}
const handleCopyText = async (text: string, label: string) => {
@@ -498,7 +507,7 @@ const handleDialogSave = async () => {
const EXTENSION_STORE_URL = 'https://chromewebstore.google.com/'
const handleInstallExtensionOnline = async () => {
try {
await invoke('downloader_open_url', { url: EXTENSION_STORE_URL })
await commands.downloaderOpenUrl(EXTENSION_STORE_URL)
} catch (e) {
try {
await navigator.clipboard.writeText(EXTENSION_STORE_URL)
@@ -537,6 +546,13 @@ onUnmounted(() => {
const allTasks = computed<DownloadTask[]>(() => store.tasks)
// 状态栏计数:单次遍历统计各状态任务数(替代模板内 4 次 filter 全量扫描)
const statusCounts = computed(() => {
const counts: Record<TaskStatus, number> = { queued: 0, active: 0, paused: 0, complete: 0, error: 0 }
for (const t of allTasks.value) counts[t.status]++
return counts
})
// 状态筛选
const filteredByStatus = computed<DownloadTask[]>(() => {
if (statusFilter.value === 'all') return allTasks.value
@@ -635,19 +651,19 @@ const toggleSortOrder = () => {
<div v-if="running" class="flex items-center gap-2 text-xs">
<Badge variant="secondary" class="gap-1">
<Download class="h-3 w-3" />
下载中 {{ allTasks.filter(t => t.status === 'active').length }}
下载中 {{ statusCounts.active }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Clock class="h-3 w-3" />
等待 {{ allTasks.filter(t => t.status === 'queued').length }}
等待 {{ statusCounts.queued }}
</Badge>
<Badge v-if="allTasks.filter(t => t.status === 'paused').length > 0" variant="secondary" class="gap-1">
<Badge v-if="statusCounts.paused > 0" variant="secondary" class="gap-1">
<Pause class="h-3 w-3" />
已暂停 {{ allTasks.filter(t => t.status === 'paused').length }}
已暂停 {{ statusCounts.paused }}
</Badge>
<Badge variant="secondary" class="gap-1">
<Check class="h-3 w-3" />
已完成 {{ allTasks.filter(t => t.status === 'complete').length }}
已完成 {{ statusCounts.complete }}
</Badge>
</div>
</div>
@@ -1457,21 +1473,21 @@ const toggleSortOrder = () => {
任务详情
</DialogTitle>
<DialogDescription class="text-xs">
任务 ID{{ detailDialogState.task?.id }}
任务 ID{{ detailTask?.id }}
</DialogDescription>
</DialogHeader>
<ScrollArea class="max-h-[55vh] pr-3">
<div v-if="detailDialogState.task" class="flex flex-col gap-3 py-2 text-sm">
<div v-if="detailTask" class="flex flex-col gap-3 py-2 text-sm">
<!-- 文件名 -->
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">文件名</span>
<span class="font-medium break-all">{{ detailDialogState.task.filename }}</span>
<span class="font-medium break-all">{{ detailTask.filename }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.filename, '文件名')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.filename, '文件名')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1484,9 +1500,9 @@ const toggleSortOrder = () => {
<!-- 状态 -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">状态</span>
<Badge :variant="getTaskStatusBadge(detailDialogState.task).variant" class="gap-1">
<component :is="getTaskStatusBadge(detailDialogState.task).icon" class="h-3 w-3" />
{{ getTaskStatusBadge(detailDialogState.task).text }}
<Badge :variant="getTaskStatusBadge(detailTask).variant" class="gap-1">
<component :is="getTaskStatusBadge(detailTask).icon" class="h-3 w-3" />
{{ getTaskStatusBadge(detailTask).text }}
</Badge>
</div>
@@ -1494,11 +1510,11 @@ const toggleSortOrder = () => {
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<span class="text-xs text-muted-foreground">下载链接</span>
<span class="font-mono text-xs break-all">{{ detailDialogState.task.url }}</span>
<span class="font-mono text-xs break-all">{{ detailTask.url }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.url, '下载链接')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.url, '下载链接')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1514,15 +1530,15 @@ const toggleSortOrder = () => {
<span class="text-xs text-muted-foreground">保存位置</span>
<Tooltip>
<TooltipTrigger as-child>
<span class="font-mono text-xs break-all cursor-default">{{ detailDialogState.task.dir }}</span>
<span class="font-mono text-xs break-all cursor-default">{{ detailTask.dir }}</span>
</TooltipTrigger>
<TooltipContent class="max-w-[400px] break-all">{{ detailDialogState.task.dir }}</TooltipContent>
<TooltipContent class="max-w-[400px] break-all">{{ detailTask.dir }}</TooltipContent>
</Tooltip>
<span class="font-mono text-xs text-muted-foreground break-all">{{ detailDialogState.task.filename }}</span>
<span class="font-mono text-xs text-muted-foreground break-all">{{ detailTask.filename }}</span>
</div>
<Tooltip>
<TooltipTrigger as-child>
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailDialogState.task.dir + '\\' + detailDialogState.task.filename, '完整路径')">
<Button size="icon" variant="ghost" class="h-7 w-7 shrink-0" @click="handleCopyText(detailTask.dir + '\\' + detailTask.filename, '完整路径')">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
@@ -1536,19 +1552,19 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">文件总大小</span>
<span>{{ formatSize(detailDialogState.task.totalSize) }}</span>
<span>{{ formatSize(detailTask.totalSize) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">已下载</span>
<span>{{ formatSize(detailDialogState.task.completedSize) }}</span>
<span>{{ formatSize(detailTask.completedSize) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">下载进度</span>
<span>{{ getProgress(detailDialogState.task) }}%</span>
<span>{{ getProgress(detailTask) }}%</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">当前速度</span>
<span v-if="detailDialogState.task.status === 'active'">{{ formatSpeed(detailDialogState.task.speed) }}</span>
<span v-if="detailTask.status === 'active'">{{ formatSpeed(detailTask.speed) }}</span>
<span v-else>-</span>
</div>
</div>
@@ -1559,41 +1575,41 @@ const toggleSortOrder = () => {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">断点续传</span>
<span>{{ detailDialogState.task.supportsResume ? '支持' : '不支持' }}</span>
<span>{{ detailTask.supportsResume ? '支持' : '不支持' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">连接数 / 分片数</span>
<span>{{ detailDialogState.task.segments.length }}</span>
<span>{{ detailTask.segments.length }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">创建时间</span>
<span>{{ formatTime(detailDialogState.task.createdAt) }}</span>
<span>{{ formatTime(detailTask.createdAt) }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-xs text-muted-foreground">剩余时间</span>
<span v-if="detailDialogState.task.status === 'active' && detailDialogState.task.speed > 0">
{{ formatEta(getEta(detailDialogState.task)) }}
<span v-if="detailTask.status === 'active' && detailTask.speed > 0">
{{ formatEta(getEta(detailTask)) }}
</span>
<span v-else>-</span>
</div>
</div>
<!-- 错误信息 -->
<template v-if="detailDialogState.task.error">
<template v-if="detailTask.error">
<Separator />
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">错误信息</span>
<span class="text-sm text-destructive break-all">{{ detailDialogState.task.error }}</span>
<span class="text-sm text-destructive break-all">{{ detailTask.error }}</span>
</div>
</template>
<!-- 自定义请求头 -->
<template v-if="detailDialogState.task.headers && Object.keys(detailDialogState.task.headers).length > 0">
<template v-if="detailTask.headers && Object.keys(detailTask.headers).length > 0">
<Separator />
<div class="flex flex-col gap-1">
<span class="text-xs text-muted-foreground">自定义请求头</span>
<div class="rounded-md bg-muted p-2 text-xs font-mono space-y-0.5">
<div v-for="(value, key) in detailDialogState.task.headers" :key="key" class="flex gap-2">
<div v-for="(value, key) in detailTask.headers" :key="key" class="flex gap-2">
<span class="text-muted-foreground shrink-0">{{ key }}:</span>
<span class="break-all">{{ value }}</span>
</div>
@@ -1602,12 +1618,12 @@ const toggleSortOrder = () => {
</template>
<!-- 分段详情 -->
<template v-if="detailDialogState.task.segments.length > 1">
<template v-if="detailTask.segments.length > 1">
<Separator />
<div class="flex flex-col gap-2">
<span class="text-xs text-muted-foreground">分段详情</span>
<div class="flex flex-col gap-1.5">
<div v-for="(seg, i) in detailDialogState.task.segments" :key="i" class="flex items-center gap-2 text-xs">
<div v-for="(seg, i) in detailTask.segments" :key="i" class="flex items-center gap-2 text-xs">
<span class="w-8 text-muted-foreground shrink-0">#{{ i }}</span>
<div class="flex-1 min-w-0">
<Progress :model-value="segmentProgress(seg)" class="h-1.5" />
@@ -1626,7 +1642,7 @@ const toggleSortOrder = () => {
<DialogClose as-child>
<Button variant="outline">关闭</Button>
</DialogClose>
<Button v-if="detailDialogState.task?.dir" variant="outline" @click="handleOpenDir(detailDialogState.task)">
<Button v-if="detailTask?.dir" variant="outline" @click="handleOpenDir(detailTask)">
<FolderOpen class="h-4 w-4" />
打开目录
</Button>
+2 -2
View File
@@ -8,7 +8,7 @@ import { moduleConfig as screenshot } from './screenshot'
import { moduleConfig as monitor } from './monitor'
import { moduleConfig as downloader } from './downloader'
import { moduleConfig as quickpanel } from './quickpanel'
import { moduleConfig as general } from './general'
import { moduleConfig as settings } from './settings'
const allModules: ModuleConfig[] = [
proxy,
@@ -17,7 +17,7 @@ const allModules: ModuleConfig[] = [
monitor,
downloader,
quickpanel,
general
settings
]
// 启动时注册所有模块
+41 -659
View File
@@ -12,11 +12,19 @@ import { toast } from 'vue-sonner'
import { VueDraggable } from 'vue-draggable-plus'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import {
useMonitorStore,
type SensorEntry,
type SensorGroup,
type ConnectionState,
type OsdConfig,
type OsdItem,
type ColorTheme,
type AlertConfig,
DEFAULT_COLOR_THEME,
} from '@/stores/monitorStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { fmt, tempColor, loadColor, fmtSpeed, typeLabel, groupDisplayName, groupIcon } from './format'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -185,90 +193,6 @@ const storageDrives = computed<StorageDrive[]>(() => {
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
// ===== 工具函数 =====
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
function fmt(v: number | null, digits = 1): string {
if (v == null || !isFinite(v)) return '--'
return v.toFixed(digits)
}
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
function tempColor(t: number | null): string {
if (t == null) return 'text-muted-foreground'
if (t < 50) return 'text-emerald-500'
if (t < 70) return 'text-yellow-500'
if (t < 85) return 'text-orange-500'
return 'text-red-500'
}
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
function loadColor(v: number | null): string {
if (v == null) return 'text-muted-foreground'
if (v < 50) return 'text-sky-500'
if (v < 80) return 'text-violet-500'
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
load: '负载',
power: '功率',
voltage: '电压',
fan: '风扇',
clock: '时钟',
data: '容量',
smalldata: '容量',
throughput: '吞吐',
level: '等级',
control: '控制',
frequency: '频率',
factor: '因子',
timespan: '时长',
energy: '能量',
noise: '噪声',
conductivity: '电导率',
humidity: '湿度',
flow: '流量',
}
function typeLabel(t: string): string {
return typeLabels[t] ?? t
}
/** 分组 id → 显示名 + 图标组件 */
const groupMeta: Record<string, { name: string; icon: typeof Cpu }> = {
cpu: { name: 'CPU', icon: Cpu },
memory: { name: '内存', icon: MemoryStick },
gpuintel: { name: 'GPU', icon: Gauge },
gpuamd: { name: 'GPU', icon: Gauge },
gpunvidia: { name: 'GPU', icon: Gauge },
storage: { name: '存储', icon: HardDrive },
motherboard: { name: '主板', icon: Activity },
superio: { name: '超级 IO', icon: Activity },
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
battery: { name: '电池', icon: Activity },
network: { name: '网络', icon: Activity },
psu: { name: '电源', icon: Zap },
}
function groupDisplayName(id: string, fallback: string): string {
return groupMeta[id]?.name ?? fallback
}
function groupIcon(id: string): typeof Cpu {
return groupMeta[id]?.icon ?? Activity
}
// ===== 连接状态徽章 =====
const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
idle: { text: '未启动', class: 'bg-muted text-muted-foreground' },
@@ -281,14 +205,19 @@ const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
// ===== 分组列表(详细页用) =====
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用) */
/** 按 hardwareName 子分组,再按 type 二级分组(详细页用)
* 分组结果只依赖传感器的静态元数据(硬件名/类型),与数值变化无关;
* 以传感器数组引用为键缓存(WeakMap),避免每次渲染对数百传感器全量重算 */
const sensorGroupCache = new WeakMap<SensorEntry[], { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[]>()
function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { type: string; items: SensorEntry[] }[] }[] {
const cached = sensorGroupCache.get(sensors)
if (cached) return cached
const byHw = new Map<string, SensorEntry[]>()
for (const s of sensors) {
if (!byHw.has(s.hardwareName)) byHw.set(s.hardwareName, [])
byHw.get(s.hardwareName)!.push(s)
}
return Array.from(byHw.entries()).map(([hw, items]) => {
const result = Array.from(byHw.entries()).map(([hw, items]) => {
const byType = new Map<string, SensorEntry[]>()
for (const s of items) {
if (!byType.has(s.type)) byType.set(s.type, [])
@@ -299,6 +228,8 @@ function groupSensors(sensors: SensorEntry[]): { hardware: string; byType: { typ
byType: Array.from(byType.entries()).map(([type, list]) => ({ type, items: list })),
}
})
sensorGroupCache.set(sensors, result)
return result
}
// ===== Accordion 折叠状态 =====
@@ -455,203 +386,12 @@ async function handleSaveConfig() {
}
}
// ===== OSD 显示配置 =====
// OSDOn-Screen Display)配置:控制传感器数据在桌面悬浮窗中的显示
// 配置持久化到 localStorage,由独立 OsdWindow.vue 消费。
/** OSD 显示项:从可用传感器中选取并排序 */
interface OsdItem {
/** 唯一 key{groupId}/{hardwareName}/{sensorName}/{type} 小写化,或 special 项的固定 key */
key: string
groupId: string
sensorName: string
hardwareName: string
type: string
unit: string
/** 特殊项标记:非 Kernel 传感器,由前端直接计算(如网速) */
special?: 'net-up' | 'net-down'
}
/** 颜色主题:按硬件/传感器类型着色(类似小飞机风格) */
interface ColorTheme {
/** 按 groupId 着色:cpu/gpu/memory/storage/... */
hardware: Record<string, string>
/** 按 sensor type 着色:temperature/load/power/... */
sensor: Record<string, string>
}
/** 警告色配置:阈值百分比 + 警告/严重颜色 */
interface AlertConfig {
/** 警告色开关 */
enabled: boolean
/** 警告阈值百分比(达到即变警告色,如 80) */
warnThreshold: number
/** 严重阈值百分比(达到即变严重色,如 90) */
criticalThreshold: number
/** 警告色(淡红,hex */
warnColor: string
/** 严重色(大红,hex */
criticalColor: string
/** 各硬件类型的最大值(用于将温度等非百分比值转为百分比)
* CPU 温度墙默认 100GPU 默认 85 */
maxValues: Record<string, number>
}
/** OSD 配置结构 */
interface OsdConfig {
overlayEnabled: boolean
overlayItems: OsdItem[]
/** 悬浮窗位置 X 百分比(0=最左,50=居中,100=最右) */
positionXPct: number
/** 悬浮窗位置 Y 百分比(0=最上,50=居中,100=最下) */
positionYPct: number
fontSize: number
showUnit: boolean
showLabel: boolean
/** 标题语言:'zh' 中文 / 'en' 英文(原始传感器名) */
labelLanguage: 'zh' | 'en'
/** 布局:'single' 单行分组式(组间用 | 分隔,固定宽度),
* 'group' 分组横排(标题在上+数据列在下),'multiline' 多行(每组一行,左对齐,类小飞机) */
layout: 'single' | 'group' | 'multiline'
updateIntervalMs: number
/** 鼠标穿透:true 时窗口不接收鼠标事件(需关闭穿透才能左键拖动) */
clickThrough: boolean
/** 默认文字颜色(hex),颜色主题关闭时使用 */
fontColor: string
/** 字体不透明度 0-100 */
fontOpacity: number
/** 悬浮窗背景色(CSS 颜色字符串,如 rgba(0,0,0,0.55) */
bgColor: string
/** 启用颜色主题(按硬件/传感器类型着色) */
colorThemeEnabled: boolean
/** 颜色主题配置 */
colorTheme: ColorTheme
/** 字体描边开关(默认关闭) */
fontStrokeEnabled: boolean
/** 字体描边厚度(px,默认 1) */
fontStrokeWidth: number
/** 字体描边颜色(hex,默认 #000000 */
fontStrokeColor: string
/** 警告色配置 */
alert: AlertConfig
/** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */
overlayX?: number | null
overlayY?: number | null
}
const OSD_STORAGE_KEY = 'thing_monitor_osd_config'
const OSD_CONFIG_VERSION = 11
/** 默认颜色主题(小飞机风格:不同硬件不同颜色,不同传感器不同颜色) */
const DEFAULT_COLOR_THEME: ColorTheme = {
hardware: {
cpu: '#4A9EFF',
gpuintel: '#9D4EFF',
gpuamd: '#9D4EFF',
gpunvidia: '#9D4EFF',
memory: '#FF9F4A',
storage: '#4AFF9F',
motherboard: '#FFD700',
superio: '#B0B0B0',
embeddedcontroller: '#B0B0B0',
battery: '#FF4A9F',
network: '#4AFFFF',
psu: '#FF4A4A',
},
sensor: {
temperature: '#FF6B6B',
load: '#4A9EFF',
power: '#FFD700',
voltage: '#9D4EFF',
fan: '#B0B0B0',
clock: '#4AFF9F',
data: '#FF9F4A',
smalldata: '#FF9F4A',
throughput: '#4AFFFF',
level: '#FF4A9F',
control: '#FFA500',
frequency: '#4AFF9F',
factor: '#FF4A4A',
timespan: '#B0B0B0',
energy: '#FFD700',
noise: '#B0B0B0',
conductivity: '#4AFFFF',
humidity: '#4A9EFF',
flow: '#4AFFFF',
},
}
/** 默认警告色配置:CPU 温度墙 100°C,GPU 85°C;百分比类直接用值 */
const DEFAULT_ALERT_CONFIG: AlertConfig = {
enabled: true,
warnThreshold: 80,
criticalThreshold: 90,
warnColor: '#FF6B6B',
criticalColor: '#FF0000',
maxValues: {
cpu: 100,
gpu: 85,
gpuintel: 85,
gpuamd: 85,
gpunvidia: 85,
},
}
function defaultOsdConfig(): OsdConfig {
return {
overlayEnabled: false,
overlayItems: [],
// 默认顶部居中(top 0):水平 50%,垂直 0%
positionXPct: 50,
positionYPct: 0,
fontSize: 14,
showUnit: true,
showLabel: true,
labelLanguage: 'zh',
layout: 'single',
updateIntervalMs: 1000,
// 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗
clickThrough: false,
fontColor: '#ffffff',
fontOpacity: 100,
bgColor: 'transparent',
colorThemeEnabled: true,
colorTheme: { ...DEFAULT_COLOR_THEME },
fontStrokeEnabled: false,
fontStrokeWidth: 1,
fontStrokeColor: '#000000',
alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } },
overlayX: null,
overlayY: null,
}
}
function loadOsdConfig(): OsdConfig {
try {
const saved = localStorage.getItem(OSD_STORAGE_KEY)
if (!saved) return defaultOsdConfig()
const parsed = JSON.parse(saved)
if (parsed.version !== OSD_CONFIG_VERSION) return defaultOsdConfig()
// 合并默认值,确保新增字段有默认值
const def = defaultOsdConfig()
return { ...def, ...parsed.config }
} catch {
return defaultOsdConfig()
}
}
function saveOsdConfig(cfg: OsdConfig) {
try {
localStorage.setItem(OSD_STORAGE_KEY, JSON.stringify({
version: OSD_CONFIG_VERSION,
config: cfg,
}))
} catch {
/* 忽略 localStorage 写入失败 */
}
}
const osdConfig = ref<OsdConfig>(loadOsdConfig())
// ===== OSD 配置(由 monitorStore 统一管理,组件仅做 UI 展示与修改) =====
// 类型/默认值/持久化/窗口管理均在 monitorStoreApp 启动时由 store.initOsd() 显式初始化
const osdConfig = computed<OsdConfig>(() => store.osdConfig)
// 保存调用点保持简洁的薄包装(内部转发到 store 的持久化函数)
const saveOsdConfig = (cfg: OsdConfig) => store.saveOsdConfig(cfg)
const saveOsdConfigDebounced = (cfg: OsdConfig) => store.saveOsdConfigDebounced(cfg)
/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */
const SENSOR_NAME_ZH: Record<string, string> = {
@@ -1056,7 +796,7 @@ const availableSensors = computed<AvailableSensor[]>(() => {
for (const g of store.snapshot?.groups ?? []) {
// 悬浮窗不显示存储分组(硬盘容量/温度等已在主界面监控,OSD 场景无需)
if (g.id === 'storage') continue
const groupName = groupMeta[g.id]?.name ?? g.name
const groupName = groupDisplayName(g.id, g.name)
for (const s of g.sensors) {
const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()
list.push({
@@ -1188,10 +928,10 @@ function removeOsdItem(key: string) {
}
}
/** OSD 配置项变更时自动保存 */
/** OSD 配置项变更时自动保存(防抖:滑块拖动期间不逐帧写盘) */
function updateOsdConfig(field: keyof OsdConfig, value: unknown) {
;(osdConfig.value as Record<string, unknown>)[field] = value
saveOsdConfig(osdConfig.value)
;(osdConfig.value as unknown as Record<string, unknown>)[field] = value
saveOsdConfigDebounced(osdConfig.value)
}
/** 解析背景色字符串为 hex + alpha0-100 */
@@ -1272,276 +1012,7 @@ function osdItemColor(item: OsdItem): string {
return withOpacity(osdConfig.value.fontColor, opacity)
}
// ===== OSD 窗口管理(实际创建/隐藏 Tauri 窗口并推送数据 =====
const OSD_OVERLAY_LABEL = 'osd-overlay'
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
let suppressPercentWatch = false
/** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */
function osdUrl(hash: string): string {
const base = window.location.href.split('#')[0]
return `${base}#${hash}`
}
/** 推送当前 OSD 状态到所有 OSD 窗口 */
async function pushOsdState() {
const payload = {
config: osdConfig.value,
snapshot: store.snapshot,
networkSpeed: store.networkSpeed,
}
try {
await emit('osd-state-update', payload)
} catch (e) {
console.error('[OSD] 推送状态失败:', e)
}
}
/** 根据百分比位置计算窗口坐标 */
function computePositionFromPct(screenW: number, screenH: number, w: number, h: number, xPct: number, yPct: number): { x: number; y: number } {
// 百分比基于可用空间(屏幕尺寸 - 窗口尺寸),确保窗口不会被定位到屏幕外
const availW = Math.max(0, screenW - w)
const availH = Math.max(0, screenH - h)
return {
x: Math.round((availW * xPct) / 100),
y: Math.round((availH * yPct) / 100),
}
}
/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素)
* single: 单行分组式,组间用 | 分隔,固定宽度数据列
* group: 分组横排,标题在上 + 数据列在下
* multiline: 多行,每组一行,标题 + 固定宽度数据列 */
function computeOsdWindowSize(
_itemCount: number,
layout: 'single' | 'group' | 'multiline',
fontSize: number,
_hasNetItem = false,
items?: OsdItem[],
): { w: number; h: number } {
const charW = fontSize * 0.62
const barHPad = 8 // osd-bar 左右 padding 4*2
// 按硬件类型分组(与渲染逻辑一致)
const groupMap = new Map<string, OsdItem[]>()
if (items?.length) {
for (const item of items) {
let gkey: string
if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network'
else if (item.groupId.startsWith('gpu')) gkey = 'gpu'
else gkey = item.groupId
if (!groupMap.has(gkey)) groupMap.set(gkey, [])
groupMap.get(gkey)!.push(item)
}
}
const groupCount = Math.max(1, groupMap.size)
// 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap)
const groupWidths: number[] = []
for (const [, groupItems] of groupMap) {
const labelW = 6
const dataW = groupItems.reduce((sum, item) => {
const isNet = item.special === 'net-up' || item.special === 'net-down'
return sum + (isNet ? 11 : 8) + 1
}, 0)
groupWidths.push(labelW + dataW)
}
if (layout === 'multiline') {
// 多行:取最宽行
const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10
const w = Math.ceil(maxLineW * charW + barHPad)
const lineH = Math.ceil(fontSize + 2)
const h = Math.ceil(groupCount * lineH + 6)
return { w: Math.max(120, w), h: Math.max(28, h) }
}
if (layout === 'group') {
// 分组横排:各组横排 + 标题行
const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4
const w = Math.ceil(totalW * charW + barHPad)
const titleH = Math.ceil(fontSize * 0.85) + 2
const dataH = Math.ceil(fontSize) + 2
const h = Math.ceil(titleH + dataH + 10)
return { w: Math.max(120, w), h: Math.max(40, h) }
}
// single:单行分组式,各组横排 + 组间 | 分隔符(1ch)
const sepW = (groupCount - 1) * 1
const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW
const w = Math.ceil(totalW * charW + barHPad)
const h = Math.ceil(fontSize + 8)
return { w: Math.max(120, w), h: Math.max(28, h) }
}
/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */
async function ensureOverlayWindow() {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
// 窗口已存在,仅显示并推送最新状态
await existing.show()
await updateOsdWindowSize()
await pushOsdState()
return
}
// 获取屏幕尺寸用于定位
const monitor = await currentMonitor()
const screenW = monitor?.size.width ?? 1920
const screenH = monitor?.size.height ?? 1080
const scale = monitor?.scaleFactor ?? 1
const logicalW = screenW / scale
const logicalH = screenH / scale
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
const { w, h } = computeOsdWindowSize(
osdConfig.value.overlayItems.length,
osdConfig.value.layout,
osdConfig.value.fontSize,
hasNetItem,
osdConfig.value.overlayItems,
)
// 优先使用保存的像素位置;否则根据百分比计算默认位置
let x: number, y: number
if (osdConfig.value.overlayX != null && osdConfig.value.overlayY != null) {
x = osdConfig.value.overlayX
y = osdConfig.value.overlayY
} else {
const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
x = pos.x
y = pos.y
}
const win = new WebviewWindow(OSD_OVERLAY_LABEL, {
url: osdUrl('osd-overlay'),
title: 'OSD 悬浮窗',
width: w,
height: h,
x,
y,
decorations: false,
transparent: true,
// 关闭窗口阴影:Win11 默认会画一圈阴影光晕,透明窗口上表现为可见的"外部框"
shadow: false,
alwaysOnTop: true,
skipTaskbar: true,
// 禁用调整大小:移除 Windows 隐形 resize 边框(该边框会拦截鼠标事件导致穿透/拖动失效)
resizable: false,
visible: true,
// 不获取焦点(NoActivate 由 Rust 后端 osd_apply_overlay_style 进一步保证)
focus: false,
})
win.once('tauri://created', async () => {
// 等待 webview 加载后推送初始状态
setTimeout(() => pushOsdState(), 300)
// 监听窗口移动,保存像素位置并同步更新百分比(拖动结束后触发)
try {
const winInstance = await win
const unlisten = await winInstance.onMoved(async ({ payload }) => {
osdConfig.value.overlayX = payload.x
osdConfig.value.overlayY = payload.y
// 反算百分比:xPct = x / availW * 100availW = screenW - windowW
// 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环
suppressPercentWatch = true
try {
const monitor = await currentMonitor()
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
const size = await winInstance.outerSize()
const scale = monitor?.scaleFactor ?? 1
const winW = size.width / scale
const winH = size.height / scale
const availW = Math.max(1, screenW - winW)
const availH = Math.max(1, screenH - winH)
osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100)
osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100)
} catch { /* 忽略百分比反算失败 */ }
saveOsdConfig(osdConfig.value)
// 下一个微任务后解除抑制(让本次 watch 回调跳过即可)
queueMicrotask(() => { suppressPercentWatch = false })
})
osdEventUnlisteners.push(unlisten)
} catch { /* 忽略 */ }
})
win.once('tauri://error', (e: unknown) => {
console.error('[OSD] 悬浮窗创建失败:', e)
toast.error('悬浮窗创建失败')
})
}
/** 隐藏悬浮窗 */
async function hideOverlayWindow() {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
await existing.hide()
}
}
/** 根据当前配置更新悬浮窗窗口尺寸(显示项数量/布局/字号变化时调用) */
async function updateOsdWindowSize() {
try {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (!existing) return
const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down')
const { w, h } = computeOsdWindowSize(
osdConfig.value.overlayItems.length,
osdConfig.value.layout,
osdConfig.value.fontSize,
hasNetItem,
osdConfig.value.overlayItems,
)
await existing.setSize(new LogicalSize(w, h))
} catch { /* 忽略 */ }
}
/** 重置悬浮窗位置到默认(百分比位置),清除保存的像素位置
* 仅重新定位,不改变尺寸——尺寸由悬浮窗内容实际测量上报维持 */
async function resetOverlayPosition() {
osdConfig.value.overlayX = null
osdConfig.value.overlayY = null
saveOsdConfig(osdConfig.value)
// 重新定位窗口
try {
const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (existing) {
const monitor = await currentMonitor()
const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1)
const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1)
// 读取窗口当前实际尺寸用于定位计算,不调用 setSize(避免覆盖实际测量值)
const size = await existing.outerSize()
const scale = monitor?.scaleFactor ?? 1
const w = size.width / scale
const h = size.height / scale
const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct)
await existing.setPosition(new LogicalPosition(pos.x, pos.y))
}
} catch { /* 忽略 */ }
}
// ===== OSD 窗口事件监听 =====
let osdEventUnlisteners: UnlistenFn[] = []
async function setupOsdEventListeners() {
// 守卫:避免重复注册(MonitorModule 可能因预渲染多次挂载)
if (osdEventUnlisteners.length) return
const { listen: tauriListen } = await import('@tauri-apps/api/event')
// 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算)
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
let lastW = 0
let lastH = 0
const unlisten = await tauriListen<{ width: number; height: number }>('osd-content-size', async (e) => {
const { width, height } = e.payload
if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return
lastW = width
lastH = height
try {
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
if (w) await w.setSize(new LogicalSize(width, height))
} catch { /* 忽略 */ }
})
osdEventUnlisteners.push(unlisten)
}
// ===== OSD 窗口管理(由 store.initOsd()/ensureOverlayWindow() 等统一管理 =====
// ===== 颜色主题编辑 Dialog =====
const colorThemeDialogOpen = ref(false)
@@ -1570,7 +1041,7 @@ function updateAlertConfig(field: keyof AlertConfig | 'maxValues', value: unknow
if (field === 'maxValues' && maxKey) {
osdConfig.value.alert.maxValues[maxKey] = Number(value)
} else {
;(osdConfig.value.alert as Record<string, unknown>)[field] = value
;(osdConfig.value.alert as unknown as Record<string, unknown>)[field] = value
}
saveOsdConfig(osdConfig.value)
}
@@ -1622,43 +1093,17 @@ onMounted(async () => {
try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ }
store.init()
// 注册 OSD 窗口事件监听
setupOsdEventListeners().catch(e => console.error('[OSD] 事件监听注册失败:', e))
// 初始化悬浮窗(如果开关已开启)
if (osdConfig.value.overlayEnabled) {
ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e))
}
// 监听托盘菜单"切换 OSD"事件
try {
osdEventUnlisteners.push(
await listen('tray:toggle-osd', () => {
osdConfig.value.overlayEnabled = !osdConfig.value.overlayEnabled
saveOsdConfig(osdConfig.value)
if (osdConfig.value.overlayEnabled) {
if (osdConfig.value.overlayItems.length === 0) {
toast.warning('OSD 显示项为空,已开启但未创建窗口')
} else {
ensureOverlayWindow().catch(e => console.error('[OSD] 托盘开启悬浮窗失败:', e))
}
} else {
hideOverlayWindow().catch(e => console.error('[OSD] 托盘关闭悬浮窗失败:', e))
}
})
)
} catch (e) {
console.error('[OSD] 注册 tray:toggle-osd 监听失败:', e)
}
// OSD 配置/窗口/事件监听已迁移至 monitorStore,由 initOsd() 统一初始化
// (幂等:App 启动时已调用过则跳过,模块挂载时再次调用安全)
store.initOsd()
})
onUnmounted(() => {
// 不 dispose storeSSE 订阅保持,确保切走监控模块后 OSD 仍有数据
// store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅
// 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理
// 仅清理组件级 OSD 事件监听(下次挂载会重新注册setupOsdEventListeners 有守卫
osdEventUnlisteners.forEach(fn => fn())
osdEventUnlisteners = []
// 释放 OSD 事件监听(App 启动或模块重新挂载会重新注册)
store.disposeOsd()
})
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
@@ -1668,71 +1113,8 @@ watch(() => store.status?.ready, (ready, prev) => {
}
})
// ===== OSD 开关变化时创建/隐藏悬浮窗 =====
watch(() => osdConfig.value.overlayEnabled, (enabled) => {
if (enabled) {
// 开启时若显示项为空则不创建窗口
if (osdConfig.value.overlayItems.length === 0) return
ensureOverlayWindow().catch(e => console.error('[OSD] 创建悬浮窗失败:', e))
} else {
hideOverlayWindow().catch(e => console.error('[OSD] 隐藏悬浮窗失败:', e))
}
})
// ===== 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在 =====
watch(() => osdConfig.value.overlayItems.length, (len) => {
if (!osdConfig.value.overlayEnabled) return
if (len === 0) {
hideOverlayWindow().catch(e => console.error('[OSD] 显示项为空,隐藏悬浮窗失败:', e))
} else {
ensureOverlayWindow().catch(e => console.error('[OSD] 显示项恢复,创建悬浮窗失败:', e))
}
})
// ===== 位置百分比变化时重新定位窗口(清除已保存像素位置) =====
// 拖动 OSD 触发的 onMoved 会反算更新百分比,此时 suppressPercentWatch=true 跳过,避免循环
watch(() => [osdConfig.value.positionXPct, osdConfig.value.positionYPct], () => {
if (suppressPercentWatch) return
// 清除保存的像素位置,让窗口使用百分比重新定位
osdConfig.value.overlayX = null
osdConfig.value.overlayY = null
saveOsdConfig(osdConfig.value)
// 如果窗口已存在,重新定位
resetOverlayPosition().catch(() => {})
})
// ===== 数据变化时推送状态到 OSD 窗口 =====
// 快照变化(Kernel SSE 推送)→ 推送到 OSD 窗口
watch(() => store.snapshot, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: false })
// 网速变化 → 推送到 OSD 窗口
watch(() => store.networkSpeed, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: false })
// OSD 配置变化 → 推送到 OSD 窗口(位置/字体/显示项等)
watch(osdConfig, () => {
if (osdConfig.value.overlayEnabled) {
pushOsdState()
}
}, { deep: true })
// 显示项数量/布局/字号变化 → 更新悬浮窗窗口尺寸(自适应内容)
watch([
() => osdConfig.value.overlayItems.length,
() => osdConfig.value.layout,
() => osdConfig.value.fontSize,
], () => {
if (osdConfig.value.overlayEnabled) {
updateOsdWindowSize().catch(() => {})
}
})
// OSD 相关 watch(开关/显示项/位置/配置/尺寸)已由 store.initOsd() 内部统一注册,
// 与组件生命周期解耦:模块卸载后 OSD 仍能持续刷新,配置变更仍会推送。
</script>
<template>
+7 -6
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { listen, emit, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/core'
import { EVENTS, WINDOWS } from '@/lib/constants'
// ===== 数据契约(与主窗口 MonitorModule 共享,此处独立声明避免循环依赖) =====
interface OsdItem {
@@ -450,7 +451,7 @@ async function measureAndReportSize() {
const rect = bar.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
// 额外留 1px 余量避免边缘裁切
await emit('osd-content-size', { width: Math.ceil(rect.width) + 1, height: Math.ceil(rect.height) + 1 })
await emit(EVENTS.osdContentSize, { width: Math.ceil(rect.width) + 1, height: Math.ceil(rect.height) + 1 })
}
/** 防抖测量(数据频繁更新时合并) */
@@ -474,7 +475,7 @@ async function applyClickThrough(ignore: boolean) {
}
// 2. Rust 原生:设置 WS_EX_TRANSPARENT 扩展样式(更可靠的原生层穿透)
try {
await invoke('osd_set_click_through', { label: 'osd-overlay', enabled: ignore })
await invoke('osd_set_click_through', { label: WINDOWS.osdOverlay, enabled: ignore })
} catch (e) {
console.error('[OSD] osd_set_click_through 失败:', e)
}
@@ -483,7 +484,7 @@ async function applyClickThrough(ignore: boolean) {
// ===== 应用置顶(使用 Rust 原生命令) =====
async function applyTopmost(topmost: boolean) {
try {
await invoke('osd_set_topmost', { label: 'osd-overlay', topmost })
await invoke('osd_set_topmost', { label: WINDOWS.osdOverlay, topmost })
} catch (e) {
console.error('[OSD] 设置置顶失败:', e)
}
@@ -498,7 +499,7 @@ watch(() => config.value?.clickThrough, (ignore) => {
onMounted(async () => {
// 应用原生样式(NoActivate + ToolWindow,不获取焦点)
try {
await invoke('osd_apply_overlay_style', { label: 'osd-overlay' })
await invoke('osd_apply_overlay_style', { label: WINDOWS.osdOverlay })
} catch (e) {
console.error('[OSD] 应用原生样式失败:', e)
}
@@ -523,11 +524,11 @@ onMounted(async () => {
}))
// 监听系统 UI 覆盖事件
unlistenFns.push(await listen('osd-system-ui-active', async () => {
unlistenFns.push(await listen(EVENTS.osdSystemUiActive, async () => {
await applyTopmost(false)
}))
unlistenFns.push(await listen('osd-system-ui-inactive', async () => {
unlistenFns.push(await listen(EVENTS.osdSystemUiInactive, async () => {
await applyTopmost(true)
}))
})
+87
View File
@@ -0,0 +1,87 @@
/**
* MonitorModule 纯工具函数:数值格式化 / 颜色 / 分组元数据。
* 不依赖组件状态,可独立测试。
*/
import { Activity, Cpu, Gauge, HardDrive, MemoryStick, Zap, type LucideIcon } from '@lucide/vue'
/** 格式化数值:整数型指标(负载/温度)保留 0 位,浮点型(电压/功率)保留 2 位 */
export function fmt(v: number | null, digits = 1): string {
if (v == null || !isFinite(v)) return '--'
return v.toFixed(digits)
}
/** 温度颜色:绿(<50) → 黄(<70) → 橙(<85) → 红(>=85) */
export function tempColor(t: number | null): string {
if (t == null) return 'text-muted-foreground'
if (t < 50) return 'text-emerald-500'
if (t < 70) return 'text-yellow-500'
if (t < 85) return 'text-orange-500'
return 'text-red-500'
}
/** 负载颜色:蓝(<50) → 紫(<80) → 红(>=80) */
export function loadColor(v: number | null): string {
if (v == null) return 'text-muted-foreground'
if (v < 50) return 'text-sky-500'
if (v < 80) return 'text-violet-500'
return 'text-red-500'
}
/** 格式化网速(bytes/s → 自适应 KB/s 或 MB/s */
export function fmtSpeed(bytesPerSec: number | null): { value: string; unit: string } {
if (bytesPerSec == null || !isFinite(bytesPerSec)) return { value: '--', unit: '' }
if (bytesPerSec >= 1_048_576) return { value: (bytesPerSec / 1_048_576).toFixed(2), unit: 'MB/s' }
if (bytesPerSec >= 1024) return { value: (bytesPerSec / 1024).toFixed(1), unit: 'KB/s' }
return { value: bytesPerSec.toFixed(0), unit: 'B/s' }
}
/** 传感器类型 → 中文标签 */
const typeLabels: Record<string, string> = {
temperature: '温度',
load: '负载',
power: '功率',
voltage: '电压',
fan: '风扇',
clock: '时钟',
data: '容量',
smalldata: '容量',
throughput: '吞吐',
level: '等级',
control: '控制',
frequency: '频率',
factor: '因子',
timespan: '时长',
energy: '能量',
noise: '噪声',
conductivity: '电导率',
humidity: '湿度',
flow: '流量',
}
export function typeLabel(t: string): string {
return typeLabels[t] ?? t
}
/** 分组 id → 显示名 + 图标组件 */
const groupMeta: Record<string, { name: string; icon: LucideIcon }> = {
cpu: { name: 'CPU', icon: Cpu },
memory: { name: '内存', icon: MemoryStick },
gpuintel: { name: 'GPU', icon: Gauge },
gpuamd: { name: 'GPU', icon: Gauge },
gpunvidia: { name: 'GPU', icon: Gauge },
storage: { name: '存储', icon: HardDrive },
motherboard: { name: '主板', icon: Activity },
superio: { name: '超级 IO', icon: Activity },
embeddedcontroller: { name: '嵌入式控制器', icon: Activity },
battery: { name: '电池', icon: Activity },
network: { name: '网络', icon: Activity },
psu: { name: '电源', icon: Zap },
}
export function groupDisplayName(id: string, fallback: string): string {
return groupMeta[id]?.name ?? fallback
}
export function groupIcon(id: string): LucideIcon {
return groupMeta[id]?.icon ?? Activity
}
+2 -1
View File
@@ -47,7 +47,8 @@ export const moduleConfig: ModuleConfig = {
// 关闭 OSD 窗口(MonitorModule onUnmounted 不再自动关闭,需在禁用时手动关闭)
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const osd = await WebviewWindow.getByLabel('osd-overlay')
const { WINDOWS } = await import('@/lib/constants')
const osd = await WebviewWindow.getByLabel(WINDOWS.osdOverlay)
if (osd) await osd.close()
} catch {
/* 忽略 */
+53 -31
View File
@@ -10,7 +10,7 @@ import { invoke } from '@tauri-apps/api/core'
import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -98,6 +98,8 @@ const autoSwitchInterval = ref(5) // 分钟
const autoSwitchTargetGroup = ref('') // 目标代理组
const autoSwitchRegion = ref('') // 空=全部,否则按地区过滤
let autoSwitchTimer: ReturnType<typeof setInterval> | null = null
/** 自动切换执行中标志(防重入:测速超时时上一轮未结束,间隔触发会重叠) */
let autoSwitchRunning = false
// 从 store.settings 同步自动切换设置
const syncAutoSwitchSettings = () => {
@@ -307,30 +309,41 @@ const loadProxiesWithError = async () => {
}
const init = async () => {
// 获取 appData 路径,用于将内核路径替换为 %APPDATA% 形式
try {
appDataPath.value = await appDataDir()
} catch {
/* 忽略 */
}
await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()])
// 同步持久化的自动切换设置
syncAutoSwitchSettings()
if (running.value) {
await store.waitForApi()
store.refreshVersion()
loadProxiesWithError()
// 若自动切换已开启,恢复定时器
if (autoSwitchEnabled.value) {
startAutoSwitch()
// 获取 appData 路径,用于将内核路径替换为 %APPDATA% 形式
try {
appDataPath.value = await appDataDir()
} catch {
/* 忽略 */
}
try {
await Promise.all([store.loadSettings(), store.refreshKernel(), store.refreshStatus()])
} catch (e) {
logger.error('代理初始化失败: ' + e)
toast.error('代理模块初始化失败', { description: String(e) })
}
// 同步持久化的自动切换设置
syncAutoSwitchSettings()
if (running.value) {
await store.waitForApi()
store.refreshVersion()
loadProxiesWithError()
// 若自动切换已开启,恢复定时器
if (autoSwitchEnabled.value) {
startAutoSwitch()
}
}
} finally {
// 无论初始化链路是否出错,都结束"加载中"占位,避免模块永久卡死
store.initialized = true
}
store.initialized = true
}
onMounted(() => {
init()
statusTimer = setInterval(async () => {
// 窗口/标签页不可见时暂停状态轮询,恢复可见后下个 tick 自动继续
if (document.hidden) return
await store.refreshStatus()
}, 3000)
})
@@ -345,17 +358,27 @@ watch(running, async (val, old) => {
await store.waitForApi()
await store.refreshVersion()
await loadProxiesWithError()
// 自动切换若已开启,mihomo 启动/重启后恢复定时器
// handleStop 会停掉旧定时器,此处统一接管启动路径,避免开关显示开但功能静默失效)
if (autoSwitchEnabled.value) {
startAutoSwitch()
}
}
})
// 切换到节点 Tab 时,加载节点列表并自动测速
// 切换到节点 Tab 时,加载节点列表并自动测速(10s 节流:快速切换 Tab 时避免重复 IPC 洪峰)
let lastAutoTestAt = 0
watch(activeTab, async (tab) => {
if (tab === 'proxies' && running.value) {
if (!Object.keys(store.proxies).length) {
await loadProxiesWithError()
}
// 自动对所有组测速一次
autoTestAllGroups()
const now = Date.now()
if (now - lastAutoTestAt > 10000) {
lastAutoTestAt = now
// 自动对所有组测速一次
autoTestAllGroups()
}
}
})
@@ -499,13 +522,14 @@ const onAutoSwitchIntervalChange = (val: unknown) => {
}
const runAutoSwitch = async () => {
const groupName = autoSwitchTargetGroup.value || mainGroupName.value
if (!groupName || !running.value) return
const nodes = filteredNodes.value
if (!nodes.length) return
toast.info('正在测试节点延迟...')
if (autoSwitchRunning) return
autoSwitchRunning = true
try {
const groupName = autoSwitchTargetGroup.value || mainGroupName.value
if (!groupName || !running.value) return
const nodes = filteredNodes.value
if (!nodes.length) return
// 使用 testDelayBatch 测速,它会更新 store.proxies[name].history
// 确保 UI 显示的延迟与选优结果一致
await store.testDelayBatch(nodes)
@@ -532,13 +556,11 @@ const runAutoSwitch = async () => {
toast.success('已自动切换到最优节点', {
description: `${best.name} (${best.delay}ms)`
})
} else {
toast.success('当前节点已是最优', {
description: `${best.name} (${best.delay}ms)`
})
}
} catch (e) {
logger.error('自动切换失败: ' + e)
} finally {
autoSwitchRunning = false
}
}
@@ -1465,7 +1487,7 @@ tabsStore.registerSave(saveSettingsForm)
</div>
<p class="text-xs text-muted-foreground truncate">{{ p.url }}</p>
<p class="text-xs text-muted-foreground">
{{ formatSize(p.size) }} · 更新于 {{ p.updatedAt }}
{{ formatSize(p.size ?? 0) }} · 更新于 {{ p.updatedAt }}
</p>
</div>
<div class="flex gap-1 shrink-0">
+5 -4
View File
@@ -1,6 +1,7 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
import { invoke } from '@tauri-apps/api/core'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
const searchItems: SearchIndexItem[] = [
{
@@ -47,9 +48,9 @@ export const moduleConfig: ModuleConfig = {
onEnable: async () => {
// 若用户在代理设置中开启了"自动启动",则随模块启用而运行 mihomo
try {
const s = await invoke<{ autoStart?: boolean }>('proxy_get_settings')
const s = await commands.proxyGetSettings()
if (s.autoStart) {
await invoke('proxy_start')
await commands.proxyStart()
}
} catch {
/* 忽略:可能内核未安装 */
@@ -58,7 +59,7 @@ export const moduleConfig: ModuleConfig = {
// 禁用模块时一并关闭系统代理,避免代理已停但系统仍指向导致无法上网
onDisable: async () => {
try {
await invoke('proxy_clear_system_proxy')
await commands.proxyClearSystemProxy()
} catch {
/* 忽略:可能内核未运行 */
}
+23 -12
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History, FolderOpen, Ruler, Trash2 } from '@lucide/vue'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
// ===== 状态 =====
const query = ref('')
@@ -29,7 +31,7 @@ const moreHistoryItems = ref<QPItem[]>([])
const moreHistoryCount = ref(0)
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
const HISTORY_KEY = 'thing_quickpanel_history'
const HISTORY_KEY = STORAGE_KEYS.quickpanelHistory
function loadHistory(): Record<string, number> {
try {
@@ -61,11 +63,17 @@ function applyHistoryBoost(items: QPItem[]): QPItem[] {
}
// ===== 搜索 =====
/** 搜索请求序号:每次 doSearch 自增,过期请求(序号落后)结果直接丢弃,防止慢请求覆盖新结果 */
let searchSeq = 0
async function doSearch() {
const seq = ++searchSeq
const q = query.value.trim()
if (!q) {
// 空查询:显示命令快捷入口 + 系统操作 + 历史(置顶3条)
results.value = applyHistoryBoost(await aggregateSearch(''))
const items = await aggregateSearch('')
if (seq !== searchSeq) return // 过期请求丢弃
results.value = applyHistoryBoost(items)
selectedIndex.value = 0
// 加载更多历史(Accordion 折叠区,不参与键盘导航)
moreHistoryItems.value = getMoreHistoryItems()
@@ -81,15 +89,18 @@ async function doSearch() {
loading.value = true
try {
const items = await aggregateSearch(q)
if (seq !== searchSeq) return // 过期请求丢弃,不覆盖新结果
results.value = applyHistoryBoost(items)
selectedIndex.value = 0
// 后台加载应用图标(不阻塞结果显示)
void loadAppIconsForResults(results.value)
} catch (e) {
if (seq !== searchSeq) return
console.error('[quickpanel] 搜索失败:', e)
results.value = []
} finally {
loading.value = false
// 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
if (seq === searchSeq) loading.value = false
}
}
@@ -103,7 +114,7 @@ watch(query, () => {
// ===== 执行与隐藏 =====
async function hideWindow() {
try {
await invoke('quickpanel_hide_popup')
await commands.quickpanelHidePopup()
} catch {
/* 忽略 */
}
@@ -145,7 +156,7 @@ async function confirmDelete() {
if (!pd) return
pendingDelete.value = null
try {
await invoke('quickpanel_delete_file', { path: pd.path })
await commands.quickpanelDeleteFile(pd.path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
@@ -294,7 +305,7 @@ const hasResults = () => results.value.length > 0
// ===== 主题应用(与主应用同步,独立窗口需自行设置) =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return {
@@ -372,13 +383,13 @@ onMounted(async () => {
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
const onStorage = (e: StorageEvent) => {
if (e.key === 'thing_app_settings') applyTheme()
if (e.key === STORAGE_KEYS.appSettings) applyTheme()
}
window.addEventListener('storage', onStorage)
unlistenFns.push(() => window.removeEventListener('storage', onStorage))
// 监听弹窗显示事件:重新同步主题 + 清空输入 + 加载初始结果
unlistenFns.push(await listen('quickpanel-show', async () => {
unlistenFns.push(await listen(EVENTS.quickpanelShow, async () => {
await applyTheme()
query.value = ''
await doSearch()
@@ -386,7 +397,7 @@ onMounted(async () => {
inputRef.value?.focus()
}))
unlistenFns.push(await listen('quickpanel-hide', () => {
unlistenFns.push(await listen(EVENTS.quickpanelHide, () => {
query.value = ''
results.value = []
}))
@@ -394,7 +405,7 @@ onMounted(async () => {
// 初始加载(空查询显示快捷入口)
// 独立窗口需自行检查文件索引状态(主窗口的 setFileIndexReady 不共享到此上下文)
try {
const stats = await invoke<{ total: number }>('quickpanel_file_index_stats')
const stats = await commands.quickpanelFileIndexStats()
setFileIndexReady((stats?.total ?? 0) > 0)
} catch {
/* 索引未初始化,忽略 */
@@ -404,7 +415,7 @@ onMounted(async () => {
inputRef.value?.focus()
try {
await invoke('quickpanel_show_window')
await commands.quickpanelShowWindow()
} catch {
/* 忽略 */
}
+11 -7
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { Command, Zap, Keyboard, Globe, Monitor, MousePointer2, FolderTree, RefreshCw, Plus, X, Loader2, Terminal, Pencil, Check } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { setFileIndexReady, invalidateCustomCommandsCache } from './providers'
import { STORAGE_KEYS } from '@/lib/constants'
interface CustomCommand {
id: string
@@ -44,7 +46,7 @@ const building = ref(false)
async function refreshStats() {
try {
indexStats.value = await invoke<IndexStats>('quickpanel_file_index_stats')
indexStats.value = await commands.quickpanelFileIndexStats()
// 索引存在(total > 0)即标记为就绪
setFileIndexReady((indexStats.value?.total ?? 0) > 0)
} catch (e) {
@@ -56,7 +58,7 @@ async function buildIndex() {
if (building.value) return
building.value = true
try {
const count = await invoke<number>('quickpanel_build_file_index')
const count = await commands.quickpanelBuildFileIndex()
toast.success(`索引完成,共 ${count}`)
await refreshStats()
} catch (e) {
@@ -88,7 +90,7 @@ function formatTime(t: number): string {
onMounted(async () => {
try {
const s = await invoke<QuickPanelSettings>('quickpanel_get_settings')
const s = await commands.quickpanelGetSettings()
Object.assign(form, s)
} catch (e) {
console.error('[quickpanel] 读取设置失败:', e)
@@ -99,9 +101,9 @@ onMounted(async () => {
// ===== 保存 =====
async function saveSettings() {
try {
await invoke('quickpanel_save_settings', { settings: { ...form } })
await commands.quickpanelSaveSettings({ ...form })
// 同步到 localStorage 供独立窗口读取
localStorage.setItem('thing_quickpanel_settings', JSON.stringify({ ...form }))
localStorage.setItem(STORAGE_KEYS.quickpanelSettings, JSON.stringify({ ...form }))
// 清除自定义命令缓存,使下次搜索重新加载
invalidateCustomCommandsCache()
toast.success('设置已保存')
@@ -231,12 +233,14 @@ async function clearShortcut() {
onUnmounted(() => {
window.removeEventListener('keydown', onRecordKey, true)
// 注销保存处理函数与标签状态,防止其他模块 activeTab=settings 时误执行本模块 saveSettings
tabsStore.unregisterTabs()
})
// ===== 唤起测试 =====
async function testPopup() {
try {
await invoke('quickpanel_show_popup')
await commands.quickpanelShowPopup()
} catch (e) {
console.error('[quickpanel] 唤起失败:', e)
toast.error('唤起失败')
+88
View File
@@ -0,0 +1,88 @@
/**
* 快速面板匹配引擎单测(Node 内置 test runner,零额外依赖)。
* 运行:npm test
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fuzzyScore, getTextForms, bestScore, type TextForms } from './engine.ts'
// ===== fuzzyScore 基础匹配 =====
test('空 query 返回 0,空 target 返回 -1', () => {
assert.equal(fuzzyScore('', 'abc'), 0)
assert.equal(fuzzyScore('abc', ''), -1)
})
test('精确匹配最高分 1.5(大小写不敏感)', () => {
assert.equal(fuzzyScore('abc', 'ABC'), 1.5)
assert.equal(fuzzyScore('hongkong', 'HongKong'), 1.5)
})
test('前缀匹配 1.2', () => {
assert.equal(fuzzyScore('ab', 'abc'), 1.2)
assert.equal(fuzzyScore('hk', 'hk-01'), 1.2)
})
test('包含匹配 1.0', () => {
assert.equal(fuzzyScore('bc', 'abc'), 1.0)
assert.equal(fuzzyScore('01', 'hk-01'), 1.0)
})
test('子序列匹配得分在 (0.5, 0.99) 区间', () => {
const s = fuzzyScore('ac', 'abc')
assert.ok(s > 0.5 && s <= 0.99, `子序列得分越界: ${s}`)
})
test('不匹配返回 -1', () => {
assert.equal(fuzzyScore('xyz', 'abc'), -1)
assert.equal(fuzzyScore('zz', 'ab'), -1)
})
test('连续命中加权高于非连续', () => {
const contiguous = fuzzyScore('ab', 'xab')
const sparse = fuzzyScore('ab', 'axb')
assert.ok(contiguous > sparse, `连续 ${contiguous} 应高于非连续 ${sparse}`)
})
test('首字母命中加权:target 开头的 query 得分更高', () => {
const atStart = fuzzyScore('a', 'abc')
const inMiddle = fuzzyScore('a', 'xac')
assert.ok(atStart > inMiddle)
})
// ===== getTextForms 形态生成(含拼音) =====
test('纯英文文本:全拼/首字母回退为原文,多单词首字母独立', () => {
const forms = getTextForms('Visual Studio Code')
assert.deepEqual(forms, ['visual studio code', 'visual studio code', 'visual studio code', 'vsc'])
})
test('中文文本生成拼音全拼与首字母', () => {
const forms = getTextForms('香港')
assert.equal(forms[0], '香港')
assert.equal(forms[1], 'xianggang')
assert.equal(forms[2], 'xg')
})
test('同一文本形态结果按内容缓存', () => {
assert.equal(getTextForms('香港'), getTextForms('香港'))
})
// ===== bestScore 多形态取最高分 =====
test('bestScore 对多形态取最高分(中文拼音可匹配)', () => {
const forms: TextForms = getTextForms('香港')
// 拼音全拼命中(子序列)
const byPinyin = bestScore('xiang', forms)
// 原文命中
const byText = bestScore('香港', forms)
assert.ok(byText >= byPinyin, `原文匹配 ${byText} 应不低于拼音 ${byPinyin}`)
assert.ok(byPinyin > 0, `拼音子序列应能匹配: ${byPinyin}`)
// 完全不匹配
assert.equal(bestScore('zzzz', forms), -1)
})
test('bestScore 支持首字母命中', () => {
const forms: TextForms = getTextForms('香港')
assert.ok(bestScore('xg', forms) > 0, '首字母应能匹配')
})
+12 -6
View File
@@ -6,7 +6,9 @@
* - query 对每种形态做子序列匹配,连续命中 + 首字母命中加权
* - 取最高分作为该 item 的得分
*
* 拼音形态惰性计算并缓存(WeakMap),避免每次输入重算。
* 拼音形态惰性计算并缓存(按文本内容缓存),避免每次输入重算。
* 注:原实现按调用方传入的 host 对象(WeakMap)缓存,但调用方每次新建对象导致缓存永不命中;
* 现改为按 text 内容缓存,同一文本直接复用结果。
*/
import { pinyin } from 'pinyin-pro'
@@ -14,7 +16,9 @@ import { pinyin } from 'pinyin-pro'
/** 一组待匹配的文本形态(原文 / 全拼或原文 / 首字母 / 多单词首字母) */
export type TextForms = readonly [string, string, string, string]
const formsCache = new WeakMap<object, TextForms>()
const formsCache = new Map<string, TextForms>()
/** 缓存上限:超过后整体清空(拼音计算开销小,缓存仅用于避免高频重复计算) */
const FORMS_CACHE_MAX = 2000
/** 判断字符串是否含 CJK 字符(需转拼音) */
function hasCJK(s: string): boolean {
@@ -38,10 +42,10 @@ function extractWordInitials(text: string): string {
/**
* 为文本生成匹配形态:[原文(小写), 拼音全拼(小写连写), 拼音首字母(小写), 多单词首字母(小写)]。
* 非中文文本:全拼与首字母回退为原文,多单词首字母仍独立计算(用于 "Visual Studio Code" → "vsc")。
* 结果按 host 对象缓存,避免重复计算。
* 结果按 text 内容缓存,避免重复计算。
*/
export function getTextForms(text: string, host: object): TextForms {
const cached = formsCache.get(host)
export function getTextForms(text: string): TextForms {
const cached = formsCache.get(text)
if (cached) return cached
const lower = text.toLowerCase()
@@ -59,7 +63,9 @@ export function getTextForms(text: string, host: object): TextForms {
const firstStr = full.map(s => s.charAt(0)).join('').toLowerCase()
forms = [lower, fullStr, firstStr, initials]
}
formsCache.set(host, forms)
formsCache.set(text, forms)
// 防止缓存无限增长(拼音计算本身开销小,超限时整体清空即可)
if (formsCache.size > FORMS_CACHE_MAX) formsCache.clear()
return forms
}
+5 -5
View File
@@ -1,5 +1,7 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
const searchItems: SearchIndexItem[] = [
{
@@ -21,11 +23,10 @@ export const moduleConfig: ModuleConfig = {
lifecycle: {
// 模块启用:读取设置并注册全局快捷键
onEnable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
const settings = await invoke<{ shortcut: string }>('quickpanel_get_settings')
const settings = await commands.quickpanelGetSettings()
if (settings.shortcut) {
await invoke('quickpanel_register_shortcut', { shortcut: settings.shortcut })
await commands.quickpanelRegisterShortcut(settings.shortcut)
}
} catch (e) {
console.error('[quickpanel] onEnable 注册快捷键失败:', e)
@@ -33,9 +34,8 @@ export const moduleConfig: ModuleConfig = {
},
// 模块禁用:注销全局快捷键
onDisable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('quickpanel_unregister_shortcut')
await commands.quickpanelUnregisterShortcut()
} catch (e) {
console.error('[quickpanel] onDisable 注销快捷键失败:', e)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
/**
* Provider 注册与聚合搜索。
* 并行调用各 Provider 合并结果、打分排序、应用去重。
*/
import type { QPItem, QPProvider } from './types'
import { appRankFromPath } from './utils'
import { HistoryProvider } from './history'
import { CommandProvider } from './command'
import { CustomCommandProvider } from './customCommand'
import { AppProvider } from './app'
import { FileProvider } from './file'
import { ClipboardProvider } from './clipboard'
import { CalcProvider } from './calc'
import { UnitProvider } from './unit'
import { SpecialProvider } from './special'
import { SystemProvider } from './system'
import { WebProvider } from './web'
let providers: QPProvider[] | null = null
export function getProviders(): QPProvider[] {
if (!providers) {
providers = [
new HistoryProvider(),
new CommandProvider(),
new CustomCommandProvider(),
new AppProvider(),
new FileProvider(),
new ClipboardProvider(),
new CalcProvider(),
new UnitProvider(),
new SpecialProvider(),
new SystemProvider(),
new WebProvider(),
]
}
return providers
}
/**
* 聚合搜索:并行调用各 Provider,合并结果,按 score 降序排序。
* 空查询时返回 command Provider 的快捷入口 + system Provider 的固定项。
*/
export async function aggregateSearch(query: string): Promise<QPItem[]> {
const all = getProviders()
const results = await Promise.all(all.map(p => Promise.resolve(p.search(query))))
const merged: QPItem[] = []
results.forEach((items, idx) => {
items.forEach(item => {
// 未打分的项赋予基础分(按 provider 优先级递减)
if (item.score === undefined) {
item.score = (10 - idx) * 0.01
}
merged.push(item)
})
})
// 去重:所有来源的「应用」(含文件索引中的 .lnk)按名称归并,保留可靠性最高的来源
// 可靠性:开始菜单(appRank 0) > 桌面(1) > 其他位置(2);同可靠性时保留分数更高的
// (如 "TRAE Work CN" 在开始菜单 + 桌面 + 某索引目录都有 .lnk,只留开始菜单那条)
const appKey = (title: string): string => {
let t = title.trim().toLowerCase()
if (t.endsWith('.lnk')) t = t.slice(0, -4).trim()
return t
}
// 应用候选:应用分组,以及文件分组中的 .lnk 快捷方式
const isAppLike = (item: QPItem): boolean => {
if (item.group === '应用') return true
if (item.group === '文件' && item.title && item.title.toLowerCase().endsWith('.lnk')) return true
return false
}
const bestAppByKey = new Map<string, QPItem>()
for (const item of merged) {
if (!isAppLike(item) || !item.title) continue
const key = appKey(item.title)
const prev = bestAppByKey.get(key)
if (!prev) {
bestAppByKey.set(key, item)
continue
}
// 比较可靠性:appRank 越小越可靠;文件分组 .lnk 无 appRank 时按路径推断
const rankOf = (i: QPItem): number => {
if (i.appRank !== undefined) return i.appRank
if (i.group === '文件') return appRankFromPath(i.subtitle ?? '')
return 2
}
const rankA = rankOf(item)
const rankB = rankOf(prev)
if (rankA < rankB || (rankA === rankB && (item.score ?? 0) > (prev.score ?? 0))) {
bestAppByKey.set(key, item)
}
}
const keptAppIds = new Set(Array.from(bestAppByKey.values()).map(i => i.id))
const deduped = merged.filter(item => {
if (!isAppLike(item)) return true
return keptAppIds.has(item.id)
})
deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
return deduped
}
+113
View File
@@ -0,0 +1,113 @@
/**
* app Provider:扫描开始菜单应用。
* 1 分钟缓存减少重复 IPC;图标按需加载(前端 Map 缓存,避免重复请求)。
*/
import { invoke } from '@tauri-apps/api/core'
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms, makeAppLaunch, makeAppSubActions } from './utils'
interface AppRecord {
name: string
path: string
}
let appCache: AppRecord[] | null = null
let appCacheTime = 0
const APP_CACHE_TTL = 60_000 // 1 分钟缓存
async function loadApps(): Promise<AppRecord[]> {
if (appCache && Date.now() - appCacheTime < APP_CACHE_TTL) {
return appCache
}
try {
const apps = await invoke<AppRecord[]>('quickpanel_scan_apps')
appCache = apps
appCacheTime = Date.now()
return apps
} catch (e) {
console.error('[quickpanel] 扫描应用失败:', e)
return []
}
}
export class AppProvider implements QPProvider {
id = 'app'
label = '应用'
priority = 95
async search(query: string): Promise<QPItem[]> {
const apps = await loadApps()
if (!query.trim()) {
// 空查询:不显示应用(避免列表过长),由命令入口承担
return []
}
const results: Array<{ item: QPItem; score: number }> = []
let idx = 0
for (const app of apps) {
const forms = buildItemForms(app.name)
const score = bestScore(query, forms)
if (score >= 0) {
results.push({
item: {
id: `app-${idx}`,
title: app.name,
subtitle: app.path,
group: '应用',
iconPath: app.path,
action: makeAppLaunch(app.path),
subActions: makeAppSubActions(app.path),
appRank: 0, // 开始菜单:最可靠来源
},
score,
})
}
idx++
}
results.sort((a, b) => b.score - a.score)
return results.slice(0, 15).map(r => ({ ...r.item, score: r.score }))
}
}
// ===== 应用图标按需加载 =====
// 前端缓存(path -> dataUrl)。Rust 侧另有内存 + 磁盘缓存,此处仅避免重复 IPC。
const appIconCache = new Map<string, string>() // path -> dataUrl('' = 无图标)
/** 为搜索结果中带 iconPath 的项(应用、历史中的应用)按需加载图标(data URL),
* 并写入 item.iconUrl 触发响应式更新。
* 命中前端缓存时同步返回;否则异步调用 Rust 命令(命中 Rust 缓存则零开销)。 */
export async function loadAppIconsForResults(items: QPItem[]): Promise<void> {
const toLoad: QPItem[] = []
for (const item of items) {
if (!item.iconPath) continue
if (item.iconUrl !== undefined) continue // 已设置(含加载中)
const cached = appIconCache.get(item.iconPath)
if (cached !== undefined) {
item.iconUrl = cached
} else {
item.iconUrl = '' // 标记加载中,避免重复请求
toLoad.push(item)
}
}
if (!toLoad.length) return
await Promise.all(
toLoad.map(async item => {
const path = item.iconPath!
try {
const url = await invoke<string | null>('quickpanel_get_app_icon', { path })
const u = url ?? ''
appIconCache.set(path, u)
item.iconUrl = u
} catch {
appIconCache.set(path, '')
item.iconUrl = ''
}
}),
)
}
/** 清空前端图标缓存(Rust 端清理命令 quickpanel_clear_app_icon_cache 调用后可一并清空) */
export function invalidateAppIconCache() {
appIconCache.clear()
}
+40
View File
@@ -0,0 +1,40 @@
/**
* calc Provider:输入即算。
* CSP 安全:使用自写递归下降求值器 evaluateExpression(原 Function 构造在启用 CSP 后会被 unsafe-eval 拦截)。
*/
import { evaluateExpression } from '@/lib/calc'
import type { QPItem, QPProvider } from './types'
const CALC_RE = /^[\d\s+\-*/().%]+$/
export class CalcProvider implements QPProvider {
id = 'calc'
label = '计算'
priority = 90
search(query: string): QPItem[] {
const trimmed = query.trim()
if (!trimmed) return []
// 必须至少包含一个运算符和一个数字
if (!CALC_RE.test(trimmed)) return []
if (!/[\d]/.test(trimmed) || !/[+\-*/%]/.test(trimmed)) return []
const result = evaluateExpression(trimmed)
if (result === null) return []
const display = String(result)
return [{
id: 'calc-result',
title: display,
subtitle: `= ${trimmed}`,
group: '计算',
score: 0.95,
action: async () => {
try {
await navigator.clipboard.writeText(display)
} catch {
/* 忽略剪贴板失败 */
}
},
}]
}
}
@@ -0,0 +1,38 @@
/**
* clipboard Provider:复用剪贴板历史。
* 剪贴板模块未启用时静默忽略(invoke 失败返回空列表)。
*/
import type { QPItem, QPProvider } from './types'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
export class ClipboardProvider implements QPProvider {
id = 'clipboard'
label = '剪贴板'
priority = 70
async search(query: string): Promise<QPItem[]> {
if (!query.trim() || query.trim().length < 2) return []
try {
// 返回 HistoryPage{ items, total }),此处取 items
const page = await commands.clipboardSearch(query.trim(), 8, 0)
return page.items.map((c) => ({
id: `clip-${c.id}`,
title: c.preview.slice(0, 80),
subtitle: `${c.kind === 'text' ? '文本' : c.kind === 'image' ? '图片' : '文件'}`,
group: '剪贴板',
score: 0.5,
action: async () => {
try {
await commands.clipboardCopyBack(c.id)
} catch (e) {
console.error('[quickpanel] 复制失败:', e)
}
},
}))
} catch {
// 剪贴板模块可能未启用,静默忽略
return []
}
}
}
@@ -0,0 +1,72 @@
/**
* command Provider:复用主应用模块搜索项。
* 独立窗口约束:不加载主应用 store,从 localStorage 读取主应用写入的命令缓存,
* 执行时 emit `quickpanel-execute-command` 事件通知主窗口切换模块。
*/
import { emit } from '@tauri-apps/api/event'
import { bestScore } from '../engine'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
const COMMANDS_KEY = STORAGE_KEYS.quickpanelCommands
interface CachedCommand {
moduleId: string
moduleName: string
title: string
description?: string
keywords: string[]
}
function loadCommands(): CachedCommand[] {
try {
const raw = localStorage.getItem(COMMANDS_KEY)
if (!raw) return []
return JSON.parse(raw) as CachedCommand[]
} catch {
return []
}
}
export class CommandProvider implements QPProvider {
id = 'command'
label = '命令'
priority = 100
search(query: string): QPItem[] {
const commands = loadCommands()
if (!query.trim() || !commands.length) {
// 无输入时返回前几条命令作为快捷入口
if (!query.trim()) {
return commands.slice(0, 6).map((c, i) => this.toItem(c, i))
}
return []
}
const results: Array<{ item: QPItem; score: number }> = []
commands.forEach((c, idx) => {
const forms = buildItemForms(c.title, c.keywords)
const score = bestScore(query, forms)
if (score >= 0) {
const item = this.toItem(c, idx)
results.push({ item, score })
}
})
results.sort((a, b) => b.score - a.score)
return results.map(r => ({ ...r.item, score: r.score }))
}
private toItem(c: CachedCommand, idx: number): QPItem {
return {
id: `cmd-${c.moduleId}-${idx}`,
title: c.title,
subtitle: c.description || c.moduleName,
group: '命令',
action: async () => {
// 通知主窗口切换到对应模块
await emit(EVENTS.quickpanelExecuteCommand, { moduleId: c.moduleId })
},
}
}
}
@@ -0,0 +1,71 @@
/**
* customCommand Provider:用户自定义命令。
* 设置保存在 Rustquickpanel_get_settings),前端缓存避免重复 IPC;
* 设置页保存后调用 invalidateCustomCommandsCache 清除缓存。
*/
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
interface CustomCommandConfig {
id: string
title: string
command: string
args: string[]
}
let customCommandsCache: CustomCommandConfig[] | null = null
async function loadCustomCommands(): Promise<CustomCommandConfig[]> {
if (customCommandsCache) return customCommandsCache
try {
const s = await commands.quickpanelGetSettings()
customCommandsCache = (s.customCommands as CustomCommandConfig[] | undefined) || []
return customCommandsCache
} catch {
return []
}
}
/** 设置页保存后调用,清除缓存使下次搜索重新加载 */
export function invalidateCustomCommandsCache() {
customCommandsCache = null
}
export class CustomCommandProvider implements QPProvider {
id = 'custom'
label = '自定义'
priority = 92
async search(query: string): Promise<QPItem[]> {
const cmds = await loadCustomCommands()
if (!query.trim()) return []
const results: Array<{ item: QPItem; score: number }> = []
for (const cmd of cmds) {
const forms = buildItemForms(cmd.title)
const score = bestScore(query, forms)
if (score >= 0) {
results.push({
item: {
id: `custom-${cmd.id}`,
title: cmd.title,
subtitle: cmd.command,
group: '自定义',
action: async () => {
try {
await commands.quickpanelRunCustomCommand(cmd.command, cmd.args)
} catch (e) {
console.error('[quickpanel] 自定义命令执行失败:', e)
}
},
},
score,
})
}
}
results.sort((a, b) => b.score - a.score)
return results.map(r => ({ ...r.item, score: r.score }))
}
}
+112
View File
@@ -0,0 +1,112 @@
/**
* file Provider:文件索引搜索。
* .lnk 快捷方式按应用处理(带图标、用启动命令),并与开始菜单应用统一去重。
*/
import type { QPItem, QPProvider } from './types'
import { appRankFromPath, makeAppLaunch, makeAppSubActions } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
let fileIndexReady = false
export class FileProvider implements QPProvider {
id = 'file'
label = '文件'
priority = 85
async search(query: string): Promise<QPItem[]> {
if (!query.trim() || query.trim().length < 2) return []
if (!fileIndexReady) return []
try {
const files = await commands.quickpanelSearchFiles(query.trim(), 20)
return files.map((f, idx) => {
// .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重
// 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥
const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk')
if (isLnk) {
return {
id: `file-app-${idx}`,
title: f.name,
subtitle: f.path,
group: '应用',
score: 0.55, // 略低于开始菜单应用(0.6+),去重时让位于开始菜单
iconPath: f.path,
action: makeAppLaunch(f.path),
subActions: makeAppSubActions(f.path, true),
deleteInfo: { path: f.path, isDir: false },
appRank: appRankFromPath(f.path),
}
}
const openFile = async () => {
try {
// 目录:Rust 端用 explorer.exe 打开;文件:默认程序打开(无关联时 fallback 打开方式)
await commands.quickpanelOpenFile(f.path)
} catch (e) {
console.error('[quickpanel] 打开文件失败:', e)
}
}
return {
id: `file-${idx}`,
title: f.name,
subtitle: f.path,
group: '文件',
score: 0.6,
action: openFile,
// 目录:打开即导航到该目录,无需再提供「在资源管理器中显示」,避免重复
subActions: [
{
id: 'open',
label: f.isDir ? '打开文件夹' : '打开',
action: openFile,
},
...(f.isDir
? []
: [{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(f.path)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
}]),
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(f.path)
} catch {
/* 忽略 */
}
},
},
{
id: 'delete',
label: '删除',
action: async () => {
try {
// 移到回收站(PowerShell + Microsoft.VisualBasic
await commands.quickpanelDeleteFile(f.path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
},
},
],
deleteInfo: { path: f.path, isDir: f.isDir },
}
})
} catch (e) {
console.error('[quickpanel] 文件搜索失败:', e)
return []
}
}
}
/** 由设置页在索引构建完成后调用,启用 file Provider */
export function setFileIndexReady(ready: boolean) {
fileIndexReady = ready
}
+107
View File
@@ -0,0 +1,107 @@
/**
* history Provider:最近交互记录。
* 记录持久化到 localStorage,空查询时置顶展示最近几条;点击历史项时
* 重新聚合搜索恢复原 action。
*/
import { STORAGE_KEYS } from '@/lib/constants'
import type { HistoryEntry, QPItem, QPProvider } from './types'
import { aggregateSearch } from './aggregate'
const HISTORY_ITEMS_KEY = STORAGE_KEYS.quickpanelHistoryItems
const HISTORY_MAX = 50
/** 空查询时默认展示的历史条数(置顶部分) */
export const HISTORY_PREVIEW_COUNT = 3
function loadHistoryEntries(): HistoryEntry[] {
try {
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
if (!raw) return []
return JSON.parse(raw) as HistoryEntry[]
} catch {
return []
}
}
function saveHistoryEntries(entries: HistoryEntry[]) {
localStorage.setItem(HISTORY_ITEMS_KEY, JSON.stringify(entries.slice(0, HISTORY_MAX)))
}
/** 将一条历史记录转换为可执行的 QPItem */
function buildHistoryItem(e: HistoryEntry): QPItem {
return {
id: `history-${e.id}`,
title: e.title,
subtitle: e.subtitle,
group: '历史',
iconPath: e.iconPath,
historyQuery: e.query,
action: async () => {
// 重新搜索恢复 action 并执行
try {
const results = await aggregateSearch(e.query)
// 按 id 精确匹配原 item
const target = results.find(r => r.id === e.id) ?? results.find(r => r.title === e.title)
if (target) {
await target.action()
}
} catch (err) {
console.error('[quickpanel] 历史项执行失败:', err)
}
},
}
}
/** 记录一次交互。在 QuickPanel.vue 执行 item 时调用。
* query 为执行时的搜索文本(用于后续重建 action)。 */
export function recordHistoryItem(item: QPItem, query: string) {
if (!item.id || item.group === '历史') return // 历史项自身不重复记录
const entries = loadHistoryEntries()
// 去重:同 id 移除旧的,插到头部
const filtered = entries.filter(e => e.id !== item.id)
filtered.unshift({
id: item.id,
title: item.title,
subtitle: item.subtitle,
group: item.group,
iconPath: item.iconPath,
query: query || item.title,
timestamp: Date.now(),
})
saveHistoryEntries(filtered.slice(0, HISTORY_MAX))
}
/** 清空历史记录 */
export function clearHistory() {
localStorage.removeItem(HISTORY_ITEMS_KEY)
}
/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */
export function getTopHistoryItems(): QPItem[] {
const entries = loadHistoryEntries()
return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
}
/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */
export function getMoreHistoryItems(): QPItem[] {
const entries = loadHistoryEntries()
return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
}
/** 获取剩余历史数量(用于 Accordion 标题显示) */
export function getMoreHistoryCount(): number {
const entries = loadHistoryEntries()
return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT)
}
export class HistoryProvider implements QPProvider {
id = 'history'
label = '历史'
priority = 99 // 最高优先级,空查询时显示在最前
async search(query: string): Promise<QPItem[]> {
if (query.trim()) return [] // 历史只在空查询时显示
// 只返回置顶3条,剩余由 Accordion 承载
return getTopHistoryItems()
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 快速面板 Provider 聚合入口。
*
* 独立窗口约束:不加载主应用 store。
* - command Provider 从 localStorage 读取主应用写入的命令缓存,
* 执行时 emit `quickpanel-execute-command` 事件通知主窗口切换模块。
* - system/web/calc Provider 纯前端 + Rust invoke。
*
* 对外保持公共 API 稳定(目录拆分后导入路径与导出名不变)。
*/
export type { QPItem, QPSubAction, QPProvider } from './types'
export { getProviders, aggregateSearch } from './aggregate'
export { loadAppIconsForResults, invalidateAppIconCache } from './app'
export { setFileIndexReady } from './file'
export { invalidateCustomCommandsCache } from './customCommand'
export {
HISTORY_PREVIEW_COUNT,
recordHistoryItem,
clearHistory,
getTopHistoryItems,
getMoreHistoryItems,
getMoreHistoryCount,
} from './history'
@@ -0,0 +1,93 @@
/**
* special ProviderWindows 常用快捷位置。
* 列表由 Rust 提供(quickpanel_get_special_locations),1 分钟缓存。
*/
import { bestScore } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定与类型(bindings.ts
import { commands, type SpecialLocation } from '@/lib/bindings'
let specialCache: SpecialLocation[] | null = null
let specialCacheTime = 0
const SPECIAL_CACHE_TTL = 60_000 // 1 分钟缓存
async function loadSpecials(): Promise<SpecialLocation[]> {
if (specialCache && Date.now() - specialCacheTime < SPECIAL_CACHE_TTL) {
return specialCache
}
try {
const list = await commands.quickpanelGetSpecialLocations()
specialCache = list
specialCacheTime = Date.now()
return list
} catch (e) {
console.error('[quickpanel] 获取快捷位置失败:', e)
return []
}
}
export class SpecialProvider implements QPProvider {
id = 'special'
label = '快捷'
priority = 60
async search(query: string): Promise<QPItem[]> {
const list = await loadSpecials()
if (!list.length) return []
if (!query.trim()) return [] // 空查询不占用列表,由用户主动搜索
const open = async (s: SpecialLocation) => {
try {
await commands.quickpanelOpenSpecial(s.kind, s.target, s.args)
} catch (e) {
console.error('[quickpanel] 打开快捷位置失败:', e)
}
}
const items: QPItem[] = list.map(s => ({
id: `sp-${s.id}`,
title: s.title,
subtitle: s.subtitle,
group: '快捷',
action: () => open(s),
subActions:
s.kind === 'file'
? [
{ id: 'open', label: '打开', action: () => open(s) },
{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(s.target)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
},
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(s.target)
} catch {
/* 忽略 */
}
},
},
]
: undefined,
}))
const scored: Array<{ item: QPItem; score: number }> = []
items.forEach((item, idx) => {
const forms = buildItemForms(item.title, list[idx].keywords)
const score = bestScore(query, forms)
if (score >= 0) scored.push({ item, score })
})
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, 8).map(s => ({ ...s.item, score: s.score }))
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* system Provider:系统操作。
* 内置常用系统命令(regedit / cmd / powershell 等),title 为中文主名,
* keywords 补充英文/别名;拼音全拼与首字母由引擎从 title 的 CJK 部分自动推导。
*/
import { invoke } from '@tauri-apps/api/core'
import { bestScore, type TextForms } from '../engine'
import type { QPItem, QPProvider } from './types'
import { buildItemForms } from './utils'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
interface SystemCommandDef {
id: string
title: string
subtitle: string
/** 额外关键词(英文命令名、中文别名等,用于匹配) */
keywords: string[]
command: string
args: string[]
}
const SYSTEM_COMMANDS: SystemCommandDef[] = [
{
id: 'sys-regedit',
title: '注册表编辑器',
subtitle: 'regedit',
keywords: ['regedit', '注册表', 'registry'],
command: 'regedit',
args: [],
},
{
id: 'sys-cmd',
title: '命令提示符',
subtitle: 'cmd',
keywords: ['cmd', '命令行', '终端', 'command'],
command: 'cmd',
args: [],
},
{
id: 'sys-powershell',
title: 'PowerShell',
subtitle: 'powershell',
keywords: ['powershell', 'pwsh'],
command: 'powershell',
args: [],
},
{
id: 'sys-taskmgr',
title: '任务管理器',
subtitle: 'taskmgr',
keywords: ['taskmgr', '任务管理', '进程'],
command: 'taskmgr',
args: [],
},
{
id: 'sys-explorer',
title: '资源管理器',
subtitle: 'explorer',
keywords: ['explorer', '文件管理器', '资源管理'],
command: 'explorer',
args: [],
},
{
id: 'sys-control',
title: '控制面板',
subtitle: 'control',
keywords: ['control', '控制面板', '设置'],
command: 'control',
args: [],
},
{
id: 'sys-shutdown',
title: '关机',
subtitle: 'shutdown /s /t 0',
keywords: ['shutdown', '关闭计算机', '关闭电脑', 'guanji'],
command: 'shutdown',
args: ['/s', '/t', '0'],
},
{
id: 'sys-restart',
title: '重启',
subtitle: 'shutdown /r /t 0',
keywords: ['restart', 'reboot', '重新启动', '重启电脑', 'chongqi'],
command: 'shutdown',
args: ['/r', '/t', '0'],
},
{
id: 'sys-shutdown-cancel',
title: '取消关机/重启',
subtitle: 'shutdown /a',
keywords: ['cancel', '取消', 'quxiao', 'abort'],
command: 'shutdown',
args: ['/a'],
},
{
id: 'sys-hibernate',
title: '休眠',
subtitle: 'shutdown /h',
keywords: ['hibernate', '睡眠', 'xiu', 'mian'],
command: 'shutdown',
args: ['/h'],
},
]
export class SystemProvider implements QPProvider {
id = 'system'
label = '系统'
priority = 40
private buildItems(): QPItem[] {
const items: QPItem[] = SYSTEM_COMMANDS.map(def => ({
id: def.id,
title: def.title,
subtitle: def.subtitle,
group: '系统',
action: async () => {
try {
await commands.quickpanelRunSystemCommand(def.command, def.args)
} catch (e) {
console.error('[quickpanel] 系统命令失败:', e)
}
},
}))
// 锁屏 + 退出 应用本身
items.push(
{
id: 'sys-lock',
title: '锁定屏幕',
subtitle: '立即锁定计算机',
group: '系统',
action: async () => {
try {
await commands.quickpanelLockScreen()
} catch (e) {
console.error('[quickpanel] 锁屏失败:', e)
}
},
},
{
id: 'sys-quit',
title: '退出 Thing',
subtitle: '关闭应用程序',
group: '系统',
action: async () => {
try {
await invoke('quit_app')
} catch (e) {
console.error('[quickpanel] 退出失败:', e)
}
},
},
)
return items
}
/** 为带 keywords 的 item 构建匹配形态(title + keywords 合并) */
private itemForms(item: QPItem): TextForms {
const def = SYSTEM_COMMANDS.find(d => d.id === item.id)
return buildItemForms(item.title, def?.keywords ?? [])
}
search(query: string): QPItem[] {
const items = this.buildItems()
if (!query.trim()) return items
const scored: Array<{ item: QPItem; score: number }> = []
for (const item of items) {
const forms = this.itemForms(item)
const score = bestScore(query, forms)
if (score >= 0) scored.push({ item, score })
}
scored.sort((a, b) => b.score - a.score)
return scored.map(s => ({ ...s.item, score: s.score }))
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 快速面板 Provider 共享类型。
* 各 Provider 实现统一 search(query) 接口,返回带 group 的 QPItem 列表。
*/
/** 子动作(项的右键/展开菜单) */
export interface QPSubAction {
id: string
label: string
action: () => void | Promise<void>
}
export interface QPItem {
id: string
title: string
subtitle?: string
group: string
score?: number
/** 应用图标 data URL'' = 加载中,undefined = 无图标项) */
iconUrl?: string
/** 应用路径(仅 app 项设置,用于按需获取图标) */
iconPath?: string
/** 执行动作(调用方在执行后负责隐藏窗口) */
action: () => void | Promise<void>
/** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */
subActions?: QPSubAction[]
/** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */
deleteInfo?: { path: string; isDir: boolean }
/** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */
historyQuery?: string
/** 应用可靠性排序(仅 group='应用' 项设置,越小越可靠:开始菜单 0 / 桌面 1 / 其他 2) */
appRank?: number
}
export interface QPProvider {
id: string
label: string
priority: number
/** 返回当前 query 的候选结果(引擎尚未打分,score 可留空) */
search(query: string): QPItem[] | Promise<QPItem[]>
}
/** 历史记录条目(history Provider 持久化到 localStorage */
export interface HistoryEntry {
id: string
title: string
subtitle?: string
group: string
iconPath?: string
/** 记录时的查询文本,用于点击历史项时重新搜索恢复 action */
query: string
timestamp: number
}
+296
View File
@@ -0,0 +1,296 @@
/**
* unit Provider:单位 / 货币 / 时间 / 温度换算。
* 汇率动态获取(open.er-api.com),带本地缓存与兜底值;温度做仿射换算单独处理。
*/
import { STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
interface UnitDef {
/** 可匹配的符号(含中文),小写优先;带 exactCase 的单位只做精确大小写匹配 */
symbols: string[]
label: string
/** 与基准单位的换算系数(基准单位 = 1) */
factor: number
/** 仅精确大小写匹配(如小写 m = 米,避免与 MB 混淆) */
exactCase?: boolean
}
interface UnitCategory {
id: string
name: string
units: UnitDef[]
}
const UNIT_CATEGORIES: UnitCategory[] = [
{
id: 'length',
name: '长度',
units: [
{ symbols: ['m', 'meter', 'meters', '米', '公尺'], label: '米', factor: 1, exactCase: true },
{ symbols: ['km', 'kilometer', 'kilometers', '千米', '公里'], label: '千米', factor: 1000 },
{ symbols: ['cm', 'centimeter', 'centimeters', '厘米'], label: '厘米', factor: 0.01 },
{ symbols: ['mm', 'millimeter', 'millimeters', '毫米'], label: '毫米', factor: 0.001 },
{ symbols: ['in', 'inch', 'inches', '英寸'], label: '英寸', factor: 0.0254 },
{ symbols: ['ft', 'foot', 'feet', '英尺'], label: '英尺', factor: 0.3048 },
{ symbols: ['yd', 'yard', 'yards', '码'], label: '码', factor: 0.9144 },
{ symbols: ['mi', 'mile', 'miles', '英里'], label: '英里', factor: 1609.344 },
{ symbols: ['里', 'li'], label: '里', factor: 500 },
],
},
{
id: 'data',
name: '数据',
units: [
{ symbols: ['b', 'byte', 'bytes', '字节'], label: '字节', factor: 1 },
{ symbols: ['kb', 'kib', 'kilobyte', 'kilobytes', '千字节'], label: 'KB', factor: 1024 },
{ symbols: ['mb', 'mib', 'megabyte', 'megabytes', '兆字节'], label: 'MB', factor: 1024 ** 2 },
{ symbols: ['gb', 'gib', 'gigabyte', 'gigabytes', '吉字节'], label: 'GB', factor: 1024 ** 3 },
{ symbols: ['tb', 'tib', 'terabyte', 'terabytes', '太字节'], label: 'TB', factor: 1024 ** 4 },
{ symbols: ['bit', 'bits', '比特'], label: 'bit', factor: 1 / 8 },
],
},
{
id: 'speed',
name: '网速',
units: [
{ symbols: ['bps', '比特/秒'], label: 'bps', factor: 1 },
{ symbols: ['kbps', '千比特/秒'], label: 'Kbps', factor: 1024 },
{ symbols: ['mbps', '兆比特/秒'], label: 'Mbps', factor: 1024 ** 2 },
{ symbols: ['gbps', '吉比特/秒'], label: 'Gbps', factor: 1024 ** 3 },
{ symbols: ['b/s'], label: 'B/s', factor: 8 },
{ symbols: ['kb/s'], label: 'KB/s', factor: 8 * 1024 },
{ symbols: ['mb/s'], label: 'MB/s', factor: 8 * 1024 ** 2 },
{ symbols: ['gb/s'], label: 'GB/s', factor: 8 * 1024 ** 3 },
],
},
{
id: 'time',
name: '时间',
units: [
{ symbols: ['s', 'sec', 'secs', 'second', 'seconds', '秒'], label: '秒', factor: 1 },
{ symbols: ['min', 'mins', 'minute', 'minutes', '分钟', '分'], label: '分钟', factor: 60 },
{ symbols: ['h', 'hr', 'hrs', 'hour', 'hours', '小时', '时'], label: '小时', factor: 3600 },
{ symbols: ['day', 'days', '天', '日'], label: '天', factor: 86400 },
{ symbols: ['week', 'weeks', '周', '星期'], label: '周', factor: 604800 },
{ symbols: ['year', 'years', '年'], label: '年', factor: 31536000 },
],
},
{
id: 'weight',
name: '重量',
units: [
{ symbols: ['kg', '千克', '公斤'], label: '千克', factor: 1 },
{ symbols: ['g', 'gram', 'grams', '克'], label: '克', factor: 0.001 },
{ symbols: ['mg', 'milligram', '毫克'], label: '毫克', factor: 1e-6 },
{ symbols: ['t', 'ton', 'tons', '吨'], label: '吨', factor: 1000 },
{ symbols: ['lb', 'lbs', 'pound', 'pounds', '磅'], label: '磅', factor: 0.45359237 },
{ symbols: ['oz', 'ounce', 'ounces', '盎司'], label: '盎司', factor: 0.028349523125 },
{ symbols: ['斤', 'jin'], label: '斤', factor: 0.5 },
{ symbols: ['两', 'liang'], label: '两', factor: 0.05 },
],
},
]
// ===== 货币换算(汇率动态获取,带本地缓存与兜底值) =====
const DEFAULT_CURRENCY_RATES: Record<string, number> = {
usd: 1,
cny: 7.2,
eur: 0.92,
gbp: 0.78,
jpy: 156,
hkd: 7.8,
}
const CURRENCY_CACHE_KEY = STORAGE_KEYS.currencyRates
function getCurrencyRates(): Record<string, number> {
try {
const raw = localStorage.getItem(CURRENCY_CACHE_KEY)
if (raw) {
const p = JSON.parse(raw)
if (p?.rates && Date.now() - p.ts < 24 * 3600 * 1000) return p.rates
}
} catch {
/* 忽略损坏缓存 */
}
return DEFAULT_CURRENCY_RATES
}
let currencyRefreshing = false
/** 后台刷新汇率(失败静默,继续用缓存/兜底值),结果写入 localStorage 供下次使用 */
async function refreshCurrencyRates() {
if (currencyRefreshing) return
currencyRefreshing = true
try {
const res = await fetch('https://open.er-api.com/v6/latest/USD')
const data = await res.json()
if (data?.result === 'success' && data.rates) {
const r = data.rates as Record<string, number | undefined>
const rates: Record<string, number> = {
usd: 1,
cny: r.CNY ?? DEFAULT_CURRENCY_RATES.cny,
eur: r.EUR ?? DEFAULT_CURRENCY_RATES.eur,
gbp: r.GBP ?? DEFAULT_CURRENCY_RATES.gbp,
jpy: r.JPY ?? DEFAULT_CURRENCY_RATES.jpy,
hkd: r.HKD ?? DEFAULT_CURRENCY_RATES.hkd,
}
localStorage.setItem(CURRENCY_CACHE_KEY, JSON.stringify({ ts: Date.now(), rates }))
}
} catch {
/* 网络失败,继续使用默认/缓存汇率 */
} finally {
currencyRefreshing = false
}
}
/** 动态构建货币类别(基准 = 美元;factor 为「1 单位该货币 = ? 美元」) */
function getCurrencyCategory(): UnitCategory {
const r = getCurrencyRates()
const perUsd = (v: number) => (v > 0 ? 1 / v : 0)
return {
id: 'currency',
name: '货币',
units: [
{ symbols: ['$', 'usd', '美元', '美金', '美刀'], label: '美元', factor: 1 },
{ symbols: ['¥', '¥', 'rmb', 'cny', '元', '人民币'], label: '人民币', factor: perUsd(r.cny) },
{ symbols: ['€', 'eur', '欧元'], label: '欧元', factor: perUsd(r.eur) },
{ symbols: ['£', 'gbp', '英镑'], label: '英镑', factor: perUsd(r.gbp) },
{ symbols: ['jpy', '日元', '日圆'], label: '日元', factor: perUsd(r.jpy) },
{ symbols: ['hkd', '港币', '港元'], label: '港元', factor: perUsd(r.hkd) },
],
}
}
/** 温度匹配(仿射换算,单独处理) */
function matchTemperature(token: string): 'C' | 'F' | 'K' | null {
const t = token.toLowerCase().replace(/°/g, '')
if (['c', 'celsius', '摄氏度', '摄氏'].includes(t)) return 'C'
if (['f', 'fahrenheit', '华氏度', '华氏'].includes(t)) return 'F'
if (['kelvin', '开尔文'].includes(t)) return 'K'
return null
}
/** 在(普通 + 货币)类别中匹配单位 token */
function matchUnit(
token: string,
categories: UnitCategory[],
): { cat: UnitCategory; unit: UnitDef } | null {
// 第一轮:精确大小写匹配
for (const cat of categories) {
for (const unit of cat.units) {
if (unit.symbols.some(s => s === token)) return { cat, unit }
}
}
// 第二轮:大小写不敏感;exactCase 单位(如 m=米)跳过,避免 "1M" 误判为 1 米
const lower = token.toLowerCase()
for (const cat of categories) {
for (const unit of cat.units) {
if (unit.exactCase) continue
if (unit.symbols.some(s => s.toLowerCase() === lower)) return { cat, unit }
}
}
return null
}
/** 数值格式化(去掉多余的浮点尾巴) */
function formatUnitValue(v: number): string {
if (!isFinite(v)) return ''
if (v === 0) return '0'
const abs = Math.abs(v)
if (abs >= 1e12) return v.toExponential(2)
if (abs >= 1e6) return Number(v.toFixed(0)).toLocaleString('en-US')
if (abs >= 1000) return Number(v.toFixed(1)).toLocaleString('en-US')
if (abs >= 100) return Number(v.toFixed(1)).toString()
if (abs >= 1) return Number(v.toFixed(2)).toString()
if (abs >= 1e-4) return Number(v.toFixed(4)).toString()
return v.toExponential(2)
}
/** 结果展示优先级:整数 > 常见量级(1~1000) > 其他 */
function unitNiceRank(v: number): number {
if (Number.isInteger(v)) return 0
const abs = Math.abs(v)
if (abs >= 1 && abs < 1000) return 1
return 2
}
function buildUnitResultItem(
value: number,
fromLabel: string,
catName: string,
toLabel: string,
toValue: number,
idx: number,
): QPItem {
const text = `${formatUnitValue(toValue)} ${toLabel}`
return {
id: `unit-${catName}-${idx}`,
title: text,
subtitle: `${value} ${fromLabel}${catName}换算)`,
group: '换算',
score: 0.85,
action: async () => {
try {
await navigator.clipboard.writeText(text)
} catch {
/* 忽略 */
}
},
}
}
export class UnitProvider implements QPProvider {
id = 'unit'
label = '换算'
priority = 80
async search(query: string): Promise<QPItem[]> {
const trimmed = query.trim()
if (!trimmed) return []
const m = trimmed.match(/^(\d+(?:\.\d+)?)\s*(.+)$/)
if (!m) return []
const value = parseFloat(m[1])
if (!isFinite(value) || value <= 0) return []
const token = m[2].trim()
if (!token) return []
// 温度(仿射换算)
const tFrom = matchTemperature(token)
if (tFrom) {
const celsius =
tFrom === 'C' ? value : tFrom === 'F' ? ((value - 32) * 5) / 9 : value - 273.15
const convs: Array<{ label: string; v: number }> = [
{ label: '摄氏度', v: celsius },
{ label: '华氏度', v: (celsius * 9) / 5 + 32 },
{ label: '开尔文', v: celsius + 273.15 },
]
return convs
.filter(c => !(tFrom === 'C' && c.label === '摄氏度') && !(tFrom === 'F' && c.label === '华氏度') && !(tFrom === 'K' && c.label === '开尔文'))
.map((c, i) => buildUnitResultItem(value, `${tFrom}°`, '温度', c.label, c.v, i))
}
// 普通单位 / 货币
const currencyCat = getCurrencyCategory()
const categories = [...UNIT_CATEGORIES, currencyCat]
const matched = matchUnit(token, categories)
if (!matched) return []
const { cat, unit } = matched
if (cat.id === 'currency') {
// 命中货币:后台刷新一次汇率,不阻塞本次结果
void refreshCurrencyRates()
}
const base = value * unit.factor
const results: Array<{ item: QPItem; rank: number }> = []
for (const u of cat.units) {
if (u === unit) continue
const v = base / u.factor
results.push({
item: buildUnitResultItem(value, unit.label, cat.name, u.label, v, results.length),
rank: unitNiceRank(v),
})
}
results.sort((a, b) => a.rank - b.rank)
return results.slice(0, 8).map(r => r.item)
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 快速面板 Provider 共享工具。
* 匹配形态构建 + 应用启动动作 / 子动作 / 可靠性排序(app 与 file Provider 共用)。
*/
import { getTextForms, type TextForms } from '../engine'
import type { QPSubAction } from './types'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
/** 由 title + keywords 组合出待匹配文本形态(engine 按文本内容缓存,无需外部 host) */
export function buildItemForms(title: string, keywords: string[] = []): TextForms {
return getTextForms([title, ...keywords].join(' '))
}
/** 启动一个应用(.lnk / .exe 等),通过 Rust spawn 子进程 */
export function makeAppLaunch(path: string) {
return async () => {
try {
// .lnk 文件不能用 openUrl 打开,需直接 spawn
await commands.quickpanelRunCustomCommand(path, [])
} catch (e) {
console.error('[quickpanel] 启动应用失败:', e)
}
}
}
/** 应用项的标准子动作:启动 / 在资源管理器中显示 / 复制路径(+ 可选删除) */
export function makeAppSubActions(path: string, includeDelete = false): QPSubAction[] {
const launch = makeAppLaunch(path)
const subs: QPSubAction[] = [
{ id: 'launch', label: '启动', action: launch },
{
id: 'reveal',
label: '在资源管理器中显示',
action: async () => {
try {
await commands.quickpanelRevealInExplorer(path)
} catch (e) {
console.error('[quickpanel] 资源管理器显示失败:', e)
}
},
},
{
id: 'copy-path',
label: '复制路径',
action: async () => {
try {
await navigator.clipboard.writeText(path)
} catch {
/* 忽略 */
}
},
},
]
if (includeDelete) {
subs.push({
id: 'delete',
label: '删除',
action: async () => {
try {
await commands.quickpanelDeleteFile(path)
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
}
},
})
}
return subs
}
/** 根据路径推断应用可靠性排序:桌面 1 / 其他位置 2(开始菜单由调用方直接给 0) */
export function appRankFromPath(path: string): number {
const p = path.toLowerCase()
if (p.includes('\\desktop\\') || p.includes('/desktop/')) return 1
return 2
}
+55
View File
@@ -0,0 +1,55 @@
/**
* web Provider:默认搜索建议。
* 搜索引擎配置来自主应用写入的 quickpanel 设置快照(localStorage)。
*/
import { openUrl } from '@tauri-apps/plugin-opener'
import { STORAGE_KEYS } from '@/lib/constants'
import type { QPItem, QPProvider } from './types'
type SearchEngine = 'google' | 'bing' | 'baidu'
const ENGINE_URL: Record<SearchEngine, string> = {
google: 'https://www.google.com/search?q=',
bing: 'https://www.bing.com/search?q=',
baidu: 'https://www.baidu.com/s?wd=',
}
function getSearchEngine(): SearchEngine {
try {
const raw = localStorage.getItem(STORAGE_KEYS.quickpanelSettings)
if (raw) {
const s = JSON.parse(raw)
if (s.searchEngine && ENGINE_URL[s.searchEngine as SearchEngine]) {
return s.searchEngine
}
}
} catch {
/* 忽略 */
}
return 'bing'
}
export class WebProvider implements QPProvider {
id = 'web'
label = '网页'
priority = 50
search(query: string): QPItem[] {
const trimmed = query.trim()
if (!trimmed) return []
const engine = getSearchEngine()
return [{
id: 'web-search',
title: `搜索「${trimmed}`,
subtitle: `${engine} 中打开`,
group: '网页',
score: 0.3,
action: async () => {
try {
await openUrl(ENGINE_URL[engine] + encodeURIComponent(trimmed))
} catch {
/* 忽略 */
}
},
}]
}
}
+21 -7
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import type { Component } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit } from '@tauri-apps/api/event'
import { EVENTS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { save } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
import {
@@ -257,6 +259,17 @@ function onMouseDown(e: MouseEvent) {
redraw()
}
// ===== 画布重绘(rAF 节流) =====
/** 连续鼠标移动时每帧最多重绘一次,避免 mousemove 高频事件(每帧多次)触发多次全量重绘 */
let redrawRaf = 0
function scheduleRedraw() {
if (redrawRaf) return
redrawRaf = requestAnimationFrame(() => {
redrawRaf = 0
redraw()
})
}
function onMouseMove(e: MouseEvent) {
if (!isDrawing.value || !draft.value) return
const p = getPoint(e)
@@ -267,7 +280,7 @@ function onMouseMove(e: MouseEvent) {
d.x2 = p.x
d.y2 = p.y
}
redraw()
scheduleRedraw()
}
function onMouseUp() {
@@ -355,9 +368,9 @@ async function copyToClipboard() {
const pngBase64 = getPngBase64()
if (!canvas || !pngBase64) return
try {
await invoke('screenshot_copy_image', { pngBase64 })
await commands.screenshotCopyImage(pngBase64)
toast.success('已复制到剪贴板')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
await emit(EVENTS.screenshotExported, { pngBase64, width: canvas.width, height: canvas.height })
} catch (e) {
toast.error('复制失败')
console.error('[screenshot-editor] 复制失败:', e)
@@ -374,9 +387,10 @@ async function saveToFile() {
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64, path })
await commands.screenshotSavePng(pngBase64, path)
toast.success('已保存')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
await emit(EVENTS.screenshotExported, { pngBase64, width: canvas.width, height: canvas.height })
await close()
} catch (e) {
toast.error('保存失败')
console.error('[screenshot-editor] 保存失败:', e)
@@ -400,7 +414,7 @@ onMounted(() => {
void (async () => {
try {
const b64 = await invoke<string | null>('screenshot_get_editor_image')
const b64 = await commands.screenshotGetEditorImage()
if (!b64) {
loadError.value = true
return
+19 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onUnmounted, watch, nextTick } from 'vue'
import {
Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer,
Image as ImageIcon, Loader2,
@@ -7,7 +7,7 @@ import {
import { toast } from 'vue-sonner'
import { open } from '@tauri-apps/plugin-dialog'
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -35,7 +35,7 @@ function formatTime(t: number): string {
}
function thumbSrc(item: RecentCapture): string {
return `data:image/png;base64,${item.pngBase64}`
return item.thumb
}
async function handleCapture() {
@@ -51,16 +51,26 @@ async function chooseSaveDir() {
}
async function handleCopy(item: RecentCapture) {
await store.copyImage(item.pngBase64)
try {
// 完整图从缓存按需加载(历史内存只保留缩略图)
const full = await store.loadFullImage(item)
await store.copyImage(full)
} catch (e) {
toast.error('加载完整图失败: ' + e)
}
}
async function handleSave(item: RecentCapture) {
await store.saveImage(item.pngBase64)
try {
const full = await store.loadFullImage(item)
await store.saveImage(full)
} catch (e) {
toast.error('加载完整图失败: ' + e)
}
}
function handleDelete(item: RecentCapture) {
const idx = store.recent.findIndex(r => r.id === item.id)
if (idx >= 0) store.recent.splice(idx, 1)
void store.removeRecent(item)
toast.success('已从历史移除')
}
@@ -149,12 +159,10 @@ async function clearShortcut() {
toast.success('已禁用截图快捷键')
}
onMounted(() => {
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
})
// 导出监听(screenshot-exported)由应用级注册(main.ts/App.vue),随应用生命周期管理;
// 模块卸载不得销毁该单例监听,否则离开截图模块后全局快捷键截图将不记录历史/不自动保存。
onUnmounted(() => {
store.destroyExportListener()
window.removeEventListener('keydown', onRecordKey, true)
})
</script>
+21 -65
View File
@@ -1,67 +1,23 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import type { Component } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import {
Square, Circle, MoveUpRight, Pencil, Type, Grid3x3, Highlighter, ListOrdered,
Undo2, Redo2, Eraser, Copy, Save,
} from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Slider } from '@/components/ui/slider'
// ===== 类型 =====
type Phase = 'pick' | 'drawing' | 'selected' | 'editing'
type ToolType = 'rect' | 'ellipse' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight' | 'number'
interface Point { x: number; y: number }
interface Sel { x: number; y: number; w: number; h: number }
interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface EllipseAnno { type: 'ellipse'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
interface NumberAnno { type: 'number'; x: number; y: number; n: number; color: string; fontSize: number }
type Annotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno | NumberAnno
type DrawableAnnotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
interface CaptureData {
pngBase64: string
width: number
height: number
}
interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
/** DWM 视觉边界(去掉最大化窗口隐形缩放边框),优先用于高亮框 */
visualRect: { x: number; y: number; width: number; height: number } | null
}
// ===== 工具与选项 =====
const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' },
{ value: 'ellipse', icon: Circle, label: '椭圆' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
{ value: 'number', icon: ListOrdered, label: '序号' },
{ value: 'pen', icon: Pencil, label: '画笔' },
{ value: 'text', icon: Type, label: '文字' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
{ value: 'highlight', icon: Highlighter, label: '高亮' },
]
const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#000000', '#ffffff']
const BLOCK_SIZES = [8, 10, 14]
const ALPHAS = [0.2, 0.4, 0.6]
const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
type HandleDir = (typeof HANDLES)[number]
const HANDLE_HIT = 10
const DRAG_THRESHOLD = 4
import {
TOOLS, COLORS, BLOCK_SIZES, ALPHAS, HANDLES, HANDLE_HIT, DRAG_THRESHOLD,
type Phase, type ToolType, type Annotation, type DrawableAnnotation,
type Point, type Sel, type CaptureData, type WindowInfo, type HandleDir,
type RectAnno, type EllipseAnno, type ArrowAnno, type PenAnno, type TextAnno,
type MosaicAnno, type HighlightAnno, type NumberAnno,
} from './types'
// ===== 窗口 / 底图 =====
const win = getCurrentWindow()
@@ -1786,7 +1742,7 @@ async function finish() {
if (!out) {
out = await cropFromStored()
if (out) {
await invoke('screenshot_copy_image', { pngBase64: out.b64 }).catch((e) =>
await commands.screenshotCopyImage(out.b64).catch((e) =>
console.error('[screenshot] 复制失败', e)
)
}
@@ -1814,7 +1770,7 @@ async function finish() {
console.error('[screenshot] raw 复制失败,回退 base64 路径', e)
out = await exportBase64()
if (out) {
await invoke('screenshot_copy_image', { pngBase64: out.b64 }).catch((e2) =>
await commands.screenshotCopyImage(out.b64).catch((e2) =>
console.error('[screenshot] 复制失败', e2)
)
}
@@ -1823,7 +1779,7 @@ async function finish() {
}
}
if (!out) return
await emit('screenshot-exported', { pngBase64: out.b64, width: out.w, height: out.h })
await emit(EVENTS.screenshotExported, { pngBase64: out.b64, width: out.w, height: out.h })
} catch (e) {
console.error('[screenshot] 完成失败', e)
} finally {
@@ -1844,8 +1800,8 @@ async function doSave() {
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64: out.b64, path })
await emit('screenshot-exported', { pngBase64: out.b64, width: out.w, height: out.h })
await commands.screenshotSavePng(out.b64, path)
await emit(EVENTS.screenshotExported, { pngBase64: out.b64, width: out.w, height: out.h })
await win.hide().catch(() => {})
} catch (e) {
console.error('[screenshot] 保存失败', e)
@@ -1878,7 +1834,7 @@ function applyTheme() {
const root = document.documentElement
let theme = 'system'
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
theme = s.theme ?? 'system'
@@ -1895,7 +1851,7 @@ function applyTheme() {
/** 主应用 localStorage 变化(主题切换)时同步主题 */
function onStorageChange(e: StorageEvent) {
if (e.key === 'thing_app_settings') {
if (e.key === STORAGE_KEYS.appSettings) {
applyTheme()
}
}
@@ -1926,12 +1882,12 @@ onMounted(async () => {
// 首次同步窗口尺寸
await refreshWinSize()
// 禁用窗口显示/隐藏过渡动画(消除进入/关闭时的缩放动画),失败静默
invoke('screenshot_disable_transitions', { label: 'screenshot-overlay' }).catch(() => {})
commands.screenshotDisableTransitions(WINDOWS.screenshotOverlay).catch(() => {})
// 先注册 begin 监听再通知 store 就绪,避免首轮事件丢失
beginUnlisten = await listen('screenshot-begin', () => {
beginUnlisten = await listen(EVENTS.screenshotBegin, () => {
void beginCapture()
})
await emit('screenshot-overlay-ready')
await emit(EVENTS.screenshotOverlayReady)
})
/** 响应 store 的 'screenshot-begin':先装载底图(隐藏中),解码完成后再一次性显示窗口 */
@@ -2017,7 +1973,7 @@ onUnmounted(() => {
magGridCanvas = null
if (objectUrl) URL.revokeObjectURL(objectUrl)
// 覆盖层窗口真正销毁(应用退出)时释放 Rust 静态中的全屏原始像素
void invoke('screenshot_clear_fullscreen').catch(() => {})
void commands.screenshotClearFullscreen().catch(() => {})
})
</script>
+60
View File
@@ -0,0 +1,60 @@
/**
* ScreenshotOverlay
* + //
*/
import type { Component } from 'vue'
import {
Circle, Grid3x3, Highlighter, ListOrdered, MoveUpRight,
Pencil, Square, Type,
} from '@lucide/vue'
export type Phase = 'pick' | 'drawing' | 'selected' | 'editing'
export type ToolType = 'rect' | 'ellipse' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight' | 'number'
export interface Point { x: number; y: number }
export interface Sel { x: number; y: number; w: number; h: number }
export interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface EllipseAnno { type: 'ellipse'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
export interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
export interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
export interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
export interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
export interface NumberAnno { type: 'number'; x: number; y: number; n: number; color: string; fontSize: number }
export type Annotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno | NumberAnno
export type DrawableAnnotation = RectAnno | EllipseAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
export interface CaptureData {
pngBase64: string
width: number
height: number
}
export interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
/** DWM 视觉边界(去掉最大化窗口隐形缩放边框),优先用于高亮框 */
visualRect: { x: number; y: number; width: number; height: number } | null
}
// ===== 工具与选项 =====
export const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' },
{ value: 'ellipse', icon: Circle, label: '椭圆' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
{ value: 'number', icon: ListOrdered, label: '序号' },
{ value: 'pen', icon: Pencil, label: '画笔' },
{ value: 'text', icon: Type, label: '文字' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
{ value: 'highlight', icon: Highlighter, label: '高亮' },
]
export const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#000000', '#ffffff']
export const BLOCK_SIZES = [8, 10, 14]
export const ALPHAS = [0.2, 0.4, 0.6]
export const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const
export type HandleDir = (typeof HANDLES)[number]
export const HANDLE_HIT = 10
export const DRAG_THRESHOLD = 4
+3 -2
View File
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
@@ -41,7 +42,7 @@ let unlistenFns: UnlistenFn[] = []
function readOsdVisible(): boolean {
try {
const raw = localStorage.getItem('thing_monitor_osd_config')
const raw = localStorage.getItem(STORAGE_KEYS.monitorOsdConfig)
if (raw) {
const parsed = JSON.parse(raw)
return parsed.config?.overlayEnabled ?? false
@@ -337,7 +338,7 @@ async function measureAndShow() {
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }