滚动截图,细节调整

This commit is contained in:
zhongluofeng
2026-09-04 19:56:23 +08:00
parent 6b9f71da08
commit 27ad5d89a5
34 changed files with 3766 additions and 559 deletions
+2
View File
@@ -263,6 +263,8 @@ onMounted(async () => {
)
// 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史)
screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
// 滚动截图完成 → 进编辑器(应用级常驻)
screenshotStore.initScrollEditorListener().catch(e => console.error('[screenshot] 滚动进编辑器监听初始化失败:', e))
})
const trayUnlisteners: UnlistenFn[] = []
+33 -4
View File
@@ -280,10 +280,31 @@ export const commands = {
screenshotEnumWindows: () => __TAURI_INVOKE<WindowInfo[]>("screenshot_enum_windows"),
/** 按 hwnd 捕获指定窗口 */
screenshotCaptureWindow: (hwnd: number) => __TAURI_INVOKE<CaptureData>("screenshot_capture_window", { hwnd }),
/** 存入编辑器图片(base64 PNG */
screenshotSetEditorImage: (pngBase64: string) => __TAURI_INVOKE<null>("screenshot_set_editor_image", { pngBase64 }),
/** 取出编辑器图片(编辑器窗口加载时调用,取出即清除) */
screenshotGetEditorImage: () => __TAURI_INVOKE<string | null>("screenshot_get_editor_image"),
/**
* 滚动截图:从窗口当前滚动位置向下拼接到底部,返回超长 PNG。
* `region` 为 Some 时仅在框选区域(屏幕物理坐标)内捕捉,宽 = 选区宽;
* 为 None 时捕捉整个客户区。结束后会把窗口滚回起始位置,不打扰用户。
*/
screenshotScrollCapture: (hwnd: number, region: {
x: number,
y: number,
width: number,
height: number,
} | null) => __TAURI_INVOKE<CaptureData>("screenshot_scroll_capture", { hwnd, region }),
/** 取消滚动截图会话(不导出)。 */
screenshotScrollCancel: () => __TAURI_INVOKE<null>("screenshot_scroll_cancel"),
/** 结束滚动截图会话并导出结果。 */
screenshotScrollFinish: () => __TAURI_INVOKE<null>("screenshot_scroll_finish"),
/**
* 启动滚动截图会话(后台线程持续捕捉拼接,实时推进度事件)。
* `auto = true` 为自动滚动(线程主动下滚拼到底部);`false` 为手动(等用户滚动窗口)。
*/
screenshotScrollStart: (hwnd: number, region: {
x: number,
y: number,
width: number,
height: number,
} | null, auto: boolean) => __TAURI_INVOKE<null>("screenshot_scroll_start", { hwnd, region, auto }),
/** 将 PNG base64 写入系统剪贴板(转 CF_DIB */
screenshotCopyImage: (pngBase64: string) => __TAURI_INVOKE<null>("screenshot_copy_image", { pngBase64 }),
/** 将 PNG base64 写入文件 */
@@ -634,6 +655,14 @@ export type ScreenRect = {
height: number,
};
/** 滚动截图区域(屏幕物理像素坐标,通常为覆盖层框选区平移到屏幕) */
export type ScrollRegion = {
x: number,
y: number,
width: number,
height: number,
};
/** 下载分段(多线程 Range 下载 / 断点续传用) */
export type Segment = {
/** 分段索引 */
+16
View File
@@ -10,6 +10,10 @@ export const WINDOWS = {
osdOverlay: 'osd-overlay',
screenshotOverlay: 'screenshot-overlay',
screenshotPin: 'screenshot-pin',
/** 常驻截图编辑器窗口(隐藏复用,打开只 show 不重建 WebView */
screenshotEditor: 'screenshot-editor',
/** 滚动截图控制窗 */
screenshotScroll: 'screenshot-scroll',
/** 单文件一次性下载窗口前缀,实际 label = `${downloadWindow}-<taskId>` */
downloadWindow: 'download-window',
} as const
@@ -46,6 +50,18 @@ export const EVENTS = {
screenshotPinReady: 'screenshot-pin-ready',
screenshotPinShow: 'screenshot-pin-show',
screenshotExported: 'screenshot-exported',
/** 滚动截图会话:实时进度 { width, height, auto } */
scrollProgress: 'screenshot-scroll-progress',
/** 滚动截图会话:完成并导出(Rust 已把 PNG 原始字节写入编辑器图片槽,事件只带 { width, height } */
scrollComplete: 'screenshot-scroll-complete',
/** 滚动截图会话:已取消 */
scrollCancelled: 'screenshot-scroll-cancelled',
/** 滚动截图完成:打开截图编辑器(编辑/裁剪后保存才进历史) */
scrollToEditor: 'screenshot-scroll-to-editor',
/** 常驻编辑器窗口挂载完成(store 等待后下发加载事件) */
screenshotEditorReady: 'screenshot-editor-ready',
/** 通知常驻编辑器窗口加载编辑器图片槽中的新图(raw IPC 取出) */
screenshotEditorLoad: 'screenshot-editor-load',
// 内核安装进度
kernelInstallProgress: 'kernel-install-progress',
// 后端自动切换节点完成(后台执行,刷新节点列表并提示)
+7 -1
View File
@@ -37,6 +37,7 @@ const standaloneWindowApps: Array<[hash: string, label: string, loader: () => Pr
['#screenshot-overlay', '截图覆盖层', () => import('./modules/screenshot/ScreenshotOverlay.vue')],
['#screenshot-editor', '截图编辑器', () => import('./modules/screenshot/ScreenshotEditor.vue')],
['#screenshot-pin', '贴图窗口', () => import('./modules/screenshot/ScreenshotPin.vue')],
['#screenshot-scroll', '滚动截图', () => import('./modules/screenshot/ScrollControl.vue')],
['#download-window', '下载窗口', () => import('./modules/downloader/DownloadWindow.vue')],
]
@@ -44,7 +45,12 @@ const winHash = window.location.hash
// #screenshot-overlay 带窗口号参数(多屏)、#download-window 带 ?task= 参数,按前缀匹配;其余精确匹配
const matched = standaloneWindowApps.find(([hash]) => {
if (hash === '#screenshot-overlay' || hash === '#download-window') return winHash.startsWith(hash)
if (
hash === '#screenshot-overlay' ||
hash === '#screenshot-scroll' ||
hash === '#download-window'
)
return winHash.startsWith(hash)
return winHash === hash
})
+14 -10
View File
@@ -7,7 +7,6 @@ import {
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
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'
import { Card, CardContent } from '@/components/ui/card'
@@ -34,7 +33,6 @@ import {
const store = useClipboardStore()
const activeTab = ref('history')
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('clipboard', activeTab, [
{ value: 'history', label: '历史' },
{ value: 'pinned', label: '固定' },
@@ -115,29 +113,35 @@ const handleEnabledToggle = async (val: boolean) => {
form.value.enabled = val
try {
await store.saveSettings({ ...form.value })
toast.success(val ? '已开启剪贴板监听' : '已停止剪贴板监听')
} catch {
toast.error('切换监听失败')
}
}
const handleSaveSettings = async () => {
// 即改即生效:其余设置改动防抖自动保存(成功不提示,失败才提示)。
// 用值与生效值是否相同来短路,避免 store round-trip 触发同步 watch 造成无限循环。
let saveFormTimer = 0
const persistSettingsForm = async () => {
if (JSON.stringify(form.value) === JSON.stringify(store.settings)) return
try {
await store.saveSettings({ ...form.value })
toast.success('设置已保存')
} catch {
toast.error('保存设置失败')
}
}
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
tabsStore.registerSave(handleSaveSettings)
watch(
form,
() => {
window.clearTimeout(saveFormTimer)
saveFormTimer = window.setTimeout(persistSettingsForm, 400)
},
{ deep: true }
)
const handleShortcutChange = async () => {
// 快捷键变化立即保存并注册(不等点击"保存设置"
// 快捷键变化立即保存并注册(快捷键显示本身已反映新值
try {
await store.saveSettings({ ...form.value })
toast.success(`快捷键已更新为 ${form.value.shortcut || '(已禁用)'}`)
} catch {
toast.error('快捷键注册失败,可能被其他程序占用')
}
+50 -66
View File
@@ -14,7 +14,6 @@ import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { commands } from '@/lib/bindings'
import { useDownloaderStore, type DownloadTask, type DownloaderSettings, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -44,7 +43,7 @@ const logger = createLogger('downloader')
// ===== 主 Tab 状态 =====
const activeTab = ref('tasks')
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('downloader', activeTab, [
{ value: 'tasks', label: '下载任务' },
{ value: 'settings', label: '设置' },
@@ -175,9 +174,9 @@ const showSecret = ref(false)
// 下载设置快捷弹窗(任务页工具栏入口)
const downloadSettingsOpen = ref(false)
// ===== 设置草案 =====
// 设置页/快捷弹窗编辑的是本地草案,点击"保存"才写入 store.settings(生效值)并持久化
// 避免未保存的编辑污染全应用读取的生效值(主页代理开关、添加任务默认值等)
// ===== 设置草案(即改即生效) =====
// 设置页/快捷弹窗编辑的是本地草案,任一处改动后防抖自动保存,无需手动"保存"
// store.settings 为全应用读取的生效值(主页代理开关、添加任务默认值等)
const settingsDraft = ref<DownloaderSettings | null>(null)
watch(
() => store.settings,
@@ -187,6 +186,27 @@ watch(
{ immediate: true, deep: true }
)
// 防抖自动保存:改动后延迟写入 store 并持久化;成功不提示,失败才 toast。
// 用值与生效值是否相同来短路,避免 store round-trip 触发上面的同步 watch 造成无限循环。
let saveDraftTimer = 0
const persistSettingsDraft = async () => {
if (!settingsDraft.value || !store.settings) return
if (JSON.stringify(settingsDraft.value) === JSON.stringify(store.settings)) return
try {
await store.saveSettings({ ...settingsDraft.value })
} catch (e) {
toast.error('保存设置失败', { description: String(e) })
}
}
watch(
settingsDraft,
() => {
window.clearTimeout(saveDraftTimer)
saveDraftTimer = window.setTimeout(persistSettingsDraft, 400)
},
{ deep: true }
)
// ===== 任务列表:筛选 / 排序 / 搜索 / 分页 =====
type StatusFilter = 'all' | TaskStatus
type SortField = 'id' | 'name' | 'size'
@@ -486,24 +506,19 @@ const handleAddDownload = async () => {
const dir = addDir.value.trim()
addingTask.value = true
try {
// HTTP 链接走原流程(含重复检查)
// HTTP 链接:立即添加(后端先建占位任务返回,探测/分段/调度在后台进行,
// 不会卡在慢速探测上;重复 URL 由后端去重,多次点击也不会堆积重复任务)
if (httpUris.length > 0) {
const checkEnabled = store.settings?.checkDuplicate ?? true
if (checkEnabled) {
duplicateSuccessCount.value = 0
processUrlsWithCheck(httpUris, dir || undefined)
} else {
let successCount = 0
for (const uri of httpUris) {
try {
await store.addTask(uri, undefined, dir || undefined, undefined, true)
successCount++
} catch (e) {
logger.error(`添加 ${uri} 失败: ` + e)
}
let successCount = 0
for (const uri of httpUris) {
try {
await store.addTask(uri, undefined, dir || undefined, undefined, true)
successCount++
} catch (e) {
logger.error(`添加 ${uri} 失败: ` + e)
}
if (successCount > 0) finishAdd(successCount)
}
if (successCount > 0) finishAdd(successCount)
}
// BT 链接:异步添加(立即返回,不卡在元数据解析)。后台解析,成功后弹文件勾选。
@@ -711,48 +726,26 @@ const handleSelectSettingsDir = async () => {
try {
const selected = await openDialog({ directory: true, multiple: false })
if (typeof selected === 'string' && settingsDraft.value) {
await store.saveSettings({ ...settingsDraft.value, downloadDir: selected })
toast.success('下载目录已更新')
settingsDraft.value.downloadDir = selected
}
} catch (e) {
toast.error('选择目录失败: ' + e)
}
}
// ===== 设置保存 =====
const handleSaveSettings = async (): Promise<boolean> => {
if (!settingsDraft.value) return false
try {
await store.saveSettings({ ...settingsDraft.value })
toast.success('设置已保存')
return true
} catch (e) {
toast.error('保存失败: ' + e)
return false
}
}
/** 切换代理开关并立即保存(新任务/探测立即生效,正在下载的任务不受影响) */
const toggleProxy = async () => {
if (!store.settings) return
const next = !store.settings.useProxy
try {
await store.saveSettings({ ...store.settings, useProxy: next })
toast.success(next ? '已启用系统代理下载' : '已切换为直连下载')
// 同步草稿,使工具栏与设置页开关状态一致
if (settingsDraft.value) settingsDraft.value.useProxy = next
} catch (e) {
toast.error('切换失败: ' + e)
}
}
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
tabsStore.registerSave(handleSaveSettings)
// Dialog 保存:成功后关闭弹窗
const handleDialogSave = async () => {
const ok = await handleSaveSettings()
if (ok) downloadSettingsOpen.value = false
}
// ===== 扩展 =====
// Thing Extension 已发布到 Microsoft Edge 加载项商店,暂定优先 Edge;后续如需支持 Chrome 商店再补充入口
const EXTENSION_STORE_URL = 'https://microsoftedge.microsoft.com/addons/detail/thing-extension/fpcffdkeemoibkhldghjbkgbgbdpofbo'
@@ -1045,7 +1038,7 @@ const toggleSortOrder = () => {
</Button>
</TooltipTrigger>
<TooltipContent>
{{ store.settings?.useProxy ? '使用系统代理(mihomo 开启系统代理时经其转发' : '直连模式' }}
{{ store.settings?.useProxy ? '使用 mihomo 代理(本地端口' : '直连模式' }}
</TooltipContent>
</Tooltip>
@@ -1392,13 +1385,13 @@ const toggleSortOrder = () => {
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="dl-use-proxy" class="cursor-pointer">使用系统代理</Label>
<span class="text-xs text-muted-foreground">下载时尊重系统代理设置(mihomo 开启系统代理时经其转发),关闭则强制直连</span>
<Label for="dl-use-proxy" class="cursor-pointer">使用 mihomo 代理</Label>
<span class="text-xs text-muted-foreground">下载时经本地 mihomo 代理(127.0.0.1:端口)转发,mihomo 未运行时自动直连;关闭则强制直连</span>
</div>
<Switch
id="dl-use-proxy"
:model-value="settingsDraft?.useProxy ?? true"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.useProxy = v)"
:model-value="store.settings?.useProxy ?? true"
@update:model-value="() => toggleProxy()"
/>
</div>
</CardContent>
@@ -1435,14 +1428,9 @@ const toggleSortOrder = () => {
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="bt-proxy" class="cursor-pointer">使用代理下载</Label>
<span class="text-xs text-muted-foreground">开启自动使用代理模块(mihomo的 SOCKS5 端口代理不可用时自动降级为直连</span>
<Label class="cursor-pointer">使用代理下载</Label>
<span class="text-xs text-muted-foreground">BT 下载是否走代理跟随上方"使用 mihomo 代理"开关:开启自动使用 mihomo 的 SOCKS5 端口代理不可用时自动降级为直连</span>
</div>
<Switch
id="bt-proxy"
:model-value="settingsDraft?.btUseProxy ?? false"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.btUseProxy = v)"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
@@ -1718,16 +1706,16 @@ const toggleSortOrder = () => {
/>
</div>
<!-- 使用系统代理 -->
<!-- 使用 mihomo 代理 -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<Label for="dl-dialog-use-proxy" class="cursor-pointer">使用系统代理</Label>
<Label for="dl-dialog-use-proxy" class="cursor-pointer">使用 mihomo 代理</Label>
<span class="text-xs text-muted-foreground">关闭则强制直连</span>
</div>
<Switch
id="dl-dialog-use-proxy"
:model-value="settingsDraft.useProxy"
@update:model-value="(v: boolean) => settingsDraft && (settingsDraft.useProxy = v)"
:model-value="store.settings?.useProxy ?? true"
@update:model-value="() => toggleProxy()"
/>
</div>
@@ -1747,12 +1735,8 @@ const toggleSortOrder = () => {
<DialogFooter>
<DialogClose as-child>
<Button variant="outline">取消</Button>
<Button variant="outline">关闭</Button>
</DialogClose>
<Button @click="handleDialogSave">
<Check class="size-4" />
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+39 -5
View File
@@ -14,6 +14,7 @@ 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 { invoke } from '@tauri-apps/api/core'
import {
useMonitorStore,
type SensorEntry,
@@ -436,6 +437,31 @@ async function handleRefresh() {
}
}
/** 检测并修复 PawnIO 驱动:安装缺失驱动并重启监控内核 */
const repairingPawnio = ref(false)
async function handleRepairPawnio() {
repairingPawnio.value = true
try {
const r = await invoke<{ installed: boolean; needReboot: boolean }>('monitor_repair_pawnio')
if (r.needReboot) {
toast.info('PawnIO 驱动已安装,需重启电脑后生效')
} else if (r.installed) {
toast.success('PawnIO 驱动已修复,监控内核已重启')
} else {
toast.error('PawnIO 驱动安装未完成', {
description: '请以管理员/提权方式运行 Thing 后重试',
})
}
store.pawnIoMissing = false
await store.refreshStatus()
await store.fetchSnapshot()
} catch (e) {
toast.error('修复 PawnIO 失败', { description: String(e) })
} finally {
repairingPawnio.value = false
}
}
/** 提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。
* Thing 以管理员权限运行时,ThingHK 子进程继承权限,ProcessManager 可直接管控,
* ThingHK 崩溃会自动重启,避免数据停止后 Thing 不感知。
@@ -535,8 +561,6 @@ async function handleSaveConfig() {
description: '主板/存储等硬件需提权才能读取完整数据,建议提权',
duration: 6000,
})
} else {
toast.success('配置已保存', { description: '正在重启 Kernel...' })
}
// 自动重启 Kernel 以应用硬件开关变更
@@ -554,7 +578,6 @@ async function handleSaveConfig() {
}
}
} else {
toast.success('配置已保存', { description: '传感器类型过滤已热生效' })
configDialogOpen.value = false
// 热更新后立即拉取新快照以反映传感器类型过滤变化
await store.fetchSnapshot()
@@ -1091,7 +1114,6 @@ function confirmOsdPick() {
osdConfig.value.overlayItems = newItems
saveOsdConfig(osdConfig.value)
osdPickDialogOpen.value = false
toast.success('悬浮窗显示项已更新')
}
/** 拖动排序结束回调 */
@@ -1230,7 +1252,6 @@ function saveColorTheme() {
osdConfig.value.colorTheme = editingColorTheme.value
saveOsdConfig(osdConfig.value)
colorThemeDialogOpen.value = false
toast.success('颜色主题已保存')
}
function resetColorTheme() {
@@ -1432,6 +1453,19 @@ watch(() => store.status?.ready, (ready, prev) => {
</CardContent>
</Card>
<!-- PawnIO 驱动缺失提示提权但驱动未装 CPU 温度/功耗无法读取 -->
<Card v-if="store.pawnIoMissing" class="py-0 gap-0 mb-2.5 !border-amber-500/40 bg-amber-500/10">
<CardContent class="px-3.5 py-2 flex flex-wrap items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle class="size-4 shrink-0" />
<span class="flex-1 min-w-[200px]">检测到 PawnIO 驱动未安装CPU 温度/功耗可能无法读取误卸载过 PawnIO 等情况会触发点击下方按钮自动安装并重启监控内核</span>
<Button size="xs" variant="outline" class="shrink-0 text-amber-600 dark:text-amber-400 border-amber-500/40 hover:bg-amber-500/10" :disabled="store.starting || store.stopping || repairingPawnio" @click="handleRepairPawnio">
<Loader2 v-if="repairingPawnio" class="size-3 mr-1 animate-spin" />
<RefreshCw v-else class="size-3 mr-1" />
{{ repairingPawnio ? '修复中...' : '检测并修复' }}
</Button>
</CardContent>
</Card>
<!-- ===== 硬件信息卡片网格模板化可编辑拖拽排序 / 删除 / 添加 ===== -->
<VueDraggable
v-model="overviewCards"
+26 -19
View File
@@ -13,7 +13,6 @@ import { appDataDir } from '@tauri-apps/api/path'
import { revealItemInDir } from '@tauri-apps/plugin-opener'
import { useProxyStore, type ProxyNode, type ProxyConnection } from '@/stores/proxyStore'
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { EVENTS } from '@/lib/constants'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -82,7 +81,6 @@ const onConfirmOpenChange = (open: boolean) => {
const activeTab = ref('overview')
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
const tabsStore = useModuleTabsStore()
const tabsListRef = useModuleTabs('proxy', activeTab, [
{ value: 'overview', label: '概览' },
{ value: 'connections', label: '连接' },
@@ -593,7 +591,6 @@ const handleStart = async () => {
toast.error('mihomo 启动超时,API 无响应')
return
}
toast.success('mihomo 已启动')
await store.refreshVersion()
await loadProxiesWithError()
} catch (e) {
@@ -607,7 +604,6 @@ const handleStop = async () => {
stopping.value = true
try {
await store.stop()
toast.success('mihomo 已停止')
} catch (e) {
toast.error('停止失败', { description: String(e) })
} finally {
@@ -624,7 +620,6 @@ const handleRestart = async () => {
toast.error('mihomo 重启超时,API 无响应')
return
}
toast.success('mihomo 已重启')
await store.refreshVersion()
await loadProxiesWithError()
} catch (e) {
@@ -645,7 +640,6 @@ const onToggleSystemProxy = async (on: boolean) => {
sysProxyLoading.value = true
try {
await store.toggleSystemProxy(on)
toast.success(on ? '系统代理已开启' : '系统代理已关闭')
} catch (e) {
toast.error('操作失败', { description: String(e) })
} finally {
@@ -666,7 +660,6 @@ const changeMode = async (mode: string) => {
if (running.value) {
await invokePatchConfigs({ mode })
}
toast.success(`已切换为${modeOptions.find(m => m.value === mode)?.label}模式`)
} catch (e) {
if (store.settings) store.settings.mode = prev
toast.error('模式切换失败', { description: String(e) })
@@ -678,7 +671,6 @@ const quickSwitchNode = async (name: string) => {
if (!mainGroupName.value) return
try {
await store.selectProxy(mainGroupName.value, name)
toast.success('节点已切换', { description: name })
// 测速新节点:用 testDelayBatch 以更新 history,保证节点 Badge 显示与结果一致
store.testDelayBatch([name]).then(() => {
const delay = store.proxies[name]?.history?.[0]?.delay
@@ -694,13 +686,6 @@ const quickSwitchNode = async (name: string) => {
// ===== 自动切换节点(执行由后端调度,前端仅负责维护设置并刷新/提示) =====
const onToggleAutoSwitch = (on: boolean) => {
autoSwitchEnabled.value = on
if (on) {
toast.success('自动切换已开启', {
description: `${autoSwitchInterval.value} 分钟测试并切换至最优节点`
})
} else {
toast.info('自动切换已关闭')
}
saveAutoSwitchSettings()
}
@@ -1117,8 +1102,6 @@ const saveSettingsForm = async () => {
localSettings.value.secret !== prev.secret
if (networkChanged && running.value) {
toast.warning('端口/接口/密钥已保存,重启 mihomo 后生效(期间代理 API 使用新地址可能暂时不可用)')
} else {
toast.success('设置已保存')
}
// 纯模式变更(网络字段未变)在运行中即时生效,与概览页行为一致,
@@ -1136,8 +1119,32 @@ const saveSettingsForm = async () => {
}
}
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
tabsStore.registerSave(saveSettingsForm)
// 即改即生效:设置改动防抖自动保存(成功不提示,失败才提示)。
// 用局部字段投影对比,避免 store round-trip 触发 syncLocalSettings 造成循环保存。
const localMatchesStore = () => {
const s = store.settings
if (!s) return false
return JSON.stringify(localSettings.value) === JSON.stringify({
mixedPort: s.mixedPort,
externalController: s.externalController,
secret: s.secret,
mode: s.mode,
logLevel: s.logLevel,
allowLan: s.allowLan,
autoStart: s.autoStart,
autoSystemProxy: s.autoSystemProxy,
})
}
let saveLocalTimer = 0
watch(
localSettings,
() => {
if (localMatchesStore()) return
window.clearTimeout(saveLocalTimer)
saveLocalTimer = window.setTimeout(() => void saveSettingsForm(), 400)
},
{ deep: true }
)
</script>
<template>
+17 -3
View File
@@ -98,6 +98,7 @@ onMounted(async () => {
} catch (e) {
console.error('[quickpanel] 读取设置失败:', e)
}
formLoaded.value = true
await refreshStats()
// 监听索引构建完成事件(闲时自动建立/重建):刷新统计,无需手动刷新
indexUpdatedUnlisten = await listen<number>(EVENTS.quickpanelIndexUpdated, () => {
@@ -105,7 +106,10 @@ onMounted(async () => {
})
})
// ===== 保存 =====
// 初始加载完成后才允许自动保存(避免启动时把刚读到的配置再写回一次)
const formLoaded = ref(false)
// ===== 保存(即改即生效) =====
async function saveSettings() {
try {
await commands.quickpanelSaveSettings({ ...form })
@@ -113,13 +117,24 @@ async function saveSettings() {
localStorage.setItem(STORAGE_KEYS.quickpanelSettings, JSON.stringify({ ...form }))
// 清除自定义命令缓存,使下次搜索重新加载
invalidateCustomCommandsCache()
toast.success('设置已保存')
} catch (e) {
console.error('[quickpanel] 保存设置失败:', e)
toast.error('保存设置失败')
}
}
// 防抖自动保存:设置任意改动后自动持久化(成功不提示,失败才提示)
let saveQueueTimer = 0
watch(
form,
() => {
if (!formLoaded.value) return
window.clearTimeout(saveQueueTimer)
saveQueueTimer = window.setTimeout(() => void saveSettings(), 400)
},
{ deep: true }
)
// ===== 自定义命令管理 =====
const editingCmd = reactive<CustomCommand>({ id: '', title: '', command: '', args: [] })
const editingIdx = ref(-1) // -1 表示新增,>=0 表示编辑现有
@@ -163,7 +178,6 @@ function removeCustomCommand(idx: number) {
}
const tabsStore = useModuleTabsStore()
tabsStore.registerSave(saveSettings)
// ===== 快捷键录入器(与 ClipboardModule 同模式) =====
const recording = ref(false)
File diff suppressed because it is too large Load Diff
+331 -47
View File
@@ -7,13 +7,13 @@ import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import {
Undo2, Redo2, Eraser, Copy, Save,
Undo2, Redo2, Eraser, Copy, Save, ChevronsDown,
} from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Slider } from '@/components/ui/slider'
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import {
TOOLS, COLORS, BLOCK_SIZES, ALPHAS, HANDLES, HANDLE_HIT, DRAG_THRESHOLD,
TOOLS, TOOL_KEYS, 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,
@@ -60,6 +60,15 @@ const sel = ref<Sel>({ x: 0, y: 0, w: 0, h: 0 })
const dragStart = ref<Point>({ x: 0, y: 0 })
const dragTracking = ref(false)
/** 滚动截图失败提示(气泡定位在选区附近,避免全屏居中落在多屏接缝处) */
const scrollToast = ref('')
let scrollToastTimer: ReturnType<typeof setTimeout> | null = null
/** 界面内滚动模式(方案 B):选区透明透出真实屏幕、遮罩保留,自动滚动拼接 */
const scrollMode = ref(false)
/** 会话实时拼接进度 */
const scrollProgress = ref<{ width: number; height: number } | null>(null)
// 窗口识别
const winHighlight = ref<{ x: number; y: number; w: number; h: number; title: string } | null>(null)
const currentHwnd = ref(0)
@@ -154,6 +163,10 @@ let magGridCanvas: HTMLCanvasElement | null = null
// 常驻窗口:'screenshot-begin' 事件监听(store 捕获并定位窗口后触发)
let beginUnlisten: UnlistenFn | null = null
const beginUnlistenCleanups: Array<() => void> = []
/** 界面内滚动会话事件监听(scrollProgress / scrollComplete / scrollCancelled */
let scrollProgressUnlisten: UnlistenFn | null = null
let scrollCompleteUnlisten: UnlistenFn | null = null
let scrollCancelledUnlisten: UnlistenFn | null = null
// 底图解码完成信号:新一轮底图 decode 完成后再显示窗口(避免"旧图→loading→新图"割裂)
let imgReadyResolve: (() => void) | null = null
@@ -355,8 +368,8 @@ const toolbarPos = computed(() => {
return { left: left + 'px', top: top + 'px' }
})
// 工具栏显示/尺寸变化后重新测量定位(两行折叠、马赛克/高亮参数按钮增减等)
watch([showToolbar, currentTool, phase], async () => {
// 工具栏显示/尺寸变化后重新测量定位(两行折叠、马赛克/高亮参数按钮增减、滚动模式切换等)
watch([showToolbar, currentTool, phase, scrollMode], async () => {
await nextTick()
toolbarTick.value++
})
@@ -413,6 +426,10 @@ function ensurePixelCanvas() {
/** 调度放大镜更新(rAF 节流,每帧最多一次) */
function scheduleMagnifier(e: MouseEvent) {
if (scrollMode.value) {
magVisible.value = false
return
}
if (loading.value || errorMsg.value) {
magVisible.value = false
return
@@ -764,6 +781,7 @@ function selectWindow(w: { x: number; y: number; w: number; h: number }) {
// ===== 鼠标交互 =====
function onMouseDown(e: MouseEvent) {
if (scrollMode.value) return
if (e.button !== 0 || loading.value || errorMsg.value) return
if (phase.value === 'pick') {
dragTracking.value = true
@@ -781,7 +799,7 @@ function onMouseDown(e: MouseEvent) {
}
function onRegionMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (e.button !== 0 || scrollMode.value) return
if (phase.value === 'selected') {
if (!hasAnnotations.value) {
const dir = hitHandle(e)
@@ -836,6 +854,7 @@ function onMouseMove(e: MouseEvent) {
// 追踪鼠标物理坐标(ESC 等回到 pick 阶段时按当前位置恢复窗口高亮)
lastMousePhys.x = Math.round(winOuterX + e.clientX * dpr)
lastMousePhys.y = Math.round(winOuterY + e.clientY * dpr)
if (scrollMode.value) return
// 取色器放大镜更新(rAF 节流)
scheduleMagnifier(e)
@@ -913,7 +932,7 @@ function onMouseMove(e: MouseEvent) {
}
function onMouseUp(e: MouseEvent) {
if (e.button !== 0) return
if (e.button !== 0 || scrollMode.value) return
if (phase.value === 'pick') {
if (dragTracking.value) {
dragTracking.value = false
@@ -948,6 +967,17 @@ function onMouseUp(e: MouseEvent) {
}
function onKeyDown(e: KeyboardEvent) {
// 界面内滚动模式:Enter 停止并导出,Esc 取消,其余按键不响应
if (scrollMode.value) {
if (e.key === 'Escape') {
e.preventDefault()
void cancelScroll()
} else if (e.key === 'Enter') {
e.preventDefault()
void stopScroll()
}
return
}
if (textInputPos.value) {
// Ctrl+Enter → 提交文字;普通 Enter → 换行(textarea 默认行为)
if (e.key === 'Enter' && e.ctrlKey) {
@@ -959,6 +989,44 @@ function onKeyDown(e: KeyboardEvent) {
}
return
}
// 编辑栏快捷键(selected/editing 阶段):单字母切工具 / S 滚动截图 / Ctrl 组合操作
if (showToolbar.value) {
const k = e.key.toLowerCase()
const mod = e.ctrlKey || e.metaKey
if (!mod && !e.altKey) {
const tool = TOOL_KEYS[k]
if (tool) {
e.preventDefault()
onSelectTool(tool)
return
}
if (k === 's') {
e.preventDefault()
void startScroll()
return
}
}
if (mod && !e.shiftKey && k === 'z') {
e.preventDefault()
undo()
return
}
if (mod && (k === 'y' || (e.shiftKey && k === 'z'))) {
e.preventDefault()
redo()
return
}
if (mod && k === 'd') {
e.preventDefault()
clearAnnotations()
return
}
if (mod && k === 's') {
e.preventDefault()
void doSave()
return
}
}
// Shift 切换颜色格式(hex → rgb → hsl → hex
if (e.key === 'Shift' && !e.repeat) {
const formats: ColorFormat[] = ['hex', 'rgb', 'hsl']
@@ -1007,7 +1075,7 @@ function onKeyDown(e: KeyboardEvent) {
}
function onRegionDblClick() {
if (textInputPos.value) return
if (textInputPos.value || scrollMode.value) return
void finish()
}
@@ -1852,6 +1920,76 @@ function cancel() {
void win.hide().catch(() => {})
}
/** 在选区附近显示滚动截图失败提示,数秒后自动消失 */
function showScrollToast(msg: string) {
scrollToast.value = msg
if (scrollToastTimer) clearTimeout(scrollToastTimer)
scrollToastTimer = setTimeout(() => {
scrollToast.value = ''
scrollToastTimer = null
}, 3200)
}
/** 界面内滚动截图:启动自动滚动会话,覆盖层保持可见,选区透明透出真实屏幕 */
async function startScroll() {
if (exporting.value || scrollMode.value) return
const sp = selPhys.value
// 目标窗口:优先窗口拾取结果;自由选区时按选区中心在拾取列表中命中(列表为截图开始时刻快照)
let hwnd = currentHwnd.value
if (!hwnd) {
const cx = Math.round(winOuterX + sp.x + sp.w / 2)
const cy = Math.round(winOuterY + sp.y + sp.h / 2)
hwnd = hitTestWindow(cx, cy)?.hwnd ?? 0
}
if (!hwnd) {
showScrollToast('选区中心处未找到可滚动窗口,请点击选中窗口后重试')
return
}
// 框选区平移到屏幕物理坐标:selPhys 已是物理像素,叠加覆盖层物理原点即绝对屏幕坐标
const region = {
x: Math.round(winOuterX + sp.x),
y: Math.round(winOuterY + sp.y),
width: Math.round(sp.w),
height: Math.round(sp.h),
}
try {
await commands.screenshotScrollStart(hwnd, region, true)
} catch (e) {
console.error('[screenshot] 滚动截图启动失败', e)
showScrollToast(String(e))
return
}
// 进入界面内滚动模式:隐藏冻结底图 → 选区透明透出真实屏幕,遮罩/蓝框保留。
// 会话用 PrintWindow 抓目标窗口客户区,覆盖层/遮罩不会出现在结果图中。
scrollMode.value = true
scrollProgress.value = null
magVisible.value = false
}
/** 退出界面内滚动模式(会话已结束/取消后恢复普通选区界面) */
function exitScrollMode() {
scrollMode.value = false
scrollProgress.value = null
}
/** 取消滚动会话并退出滚动模式(会话线程回滚窗口、丢弃画布) */
async function cancelScroll() {
try {
await commands.screenshotScrollCancel()
} catch { /* 会话可能已结束 */ }
exitScrollMode()
}
/** 手动停止:请求会话结束并导出当前已拼接内容(随后进入编辑器) */
async function stopScroll() {
try {
await commands.screenshotScrollFinish()
} catch {
// 会话可能已提前结束(自动到底/窗口关闭等),退出滚动模式即可,无需报错
exitScrollMode()
}
}
// ===== 生命周期 =====
function onImgLoad() {
if (imgEl.value) {
@@ -1923,6 +2061,29 @@ onMounted(async () => {
beginUnlisten = await listen<ScreenshotBeginPayload>(EVENTS.screenshotBegin, (e) => {
void beginCapture(e.payload)
})
// 界面内滚动会话事件(仅滚动模式下有意义;会话由主窗口触发、覆盖层常驻监听)
scrollProgressUnlisten = await listen<{ width: number; height: number }>(EVENTS.scrollProgress, (e) => {
if (!scrollMode.value) return
scrollProgress.value = { width: e.payload.width, height: e.payload.height }
})
scrollCompleteUnlisten = await listen<{ width: number; height: number }>(EVENTS.scrollComplete, (e) => {
if (!scrollMode.value) return
const regionH = selPhys.value.h
const got = e.payload.height > regionH
exitScrollMode()
if (!got) {
// 没有滚动内容(窗口未响应自动滚动 / 选区在固定区域):保留选区并提示
showScrollToast('未检测到滚动内容,窗口可能不支持自动滚动,或选区不含可滚动内容')
return
}
void win.hide().catch(() => {})
// 通知主窗口打开常驻编辑器(PNG 原始字节已由 Rust 写入编辑器图片槽,raw IPC 取出)
void emit(EVENTS.scrollToEditor, {}).catch(() => {})
})
scrollCancelledUnlisten = await listen(EVENTS.scrollCancelled, () => {
if (!scrollMode.value) return
exitScrollMode()
})
await emit(EVENTS.screenshotOverlayReady)
})
@@ -1934,7 +2095,13 @@ async function beginCapture(payload?: ScreenshotBeginPayload) {
// 每次截图开始时同步主界面主题(覆盖层是常驻窗口,主界面可能已切换主题)
applyTheme()
// 重置到"拾取"初始状态
const wasScrolling = scrollMode.value
phase.value = 'pick'
// 若上一轮滚动会话状态残留(异常路径),一并复位
scrollMode.value = false
scrollProgress.value = null
// 新截图打断进行中的滚动会话 → 取消后台会话(避免残留占用)
if (wasScrolling) void commands.screenshotScrollCancel().catch(() => {})
winHighlight.value = null
currentHwnd.value = 0
sel.value = { x: 0, y: 0, w: 0, h: 0 }
@@ -2017,12 +2184,19 @@ onUnmounted(() => {
window.removeEventListener('storage', onStorageChange)
beginUnlisten?.()
beginUnlisten = null
scrollProgressUnlisten?.()
scrollProgressUnlisten = null
scrollCompleteUnlisten?.()
scrollCompleteUnlisten = null
scrollCancelledUnlisten?.()
scrollCancelledUnlisten = null
beginUnlistenCleanups.forEach(fn => fn())
beginUnlistenCleanups.length = 0
if (pickRaf) cancelAnimationFrame(pickRaf)
if (magRaf) cancelAnimationFrame(magRaf)
if (redrawRaf) cancelAnimationFrame(redrawRaf)
if (enterTimer !== null) clearTimeout(enterTimer)
if (scrollToastTimer !== null) clearTimeout(scrollToastTimer)
staticCanvas = null
magGridCanvas = null
if (objectUrl) URL.revokeObjectURL(objectUrl)
@@ -2040,9 +2214,9 @@ onUnmounted(() => {
@mouseup="onMouseUp"
>
<TooltipProvider>
<!-- 冻结的屏幕底图 -->
<!-- 冻结的屏幕底图滚动模式下隐藏选区透明透出真实屏幕 -->
<img
v-if="imgSrc"
v-if="imgSrc && !scrollMode"
ref="imgEl"
:src="imgSrc"
class="bg-img"
@@ -2057,6 +2231,16 @@ onUnmounted(() => {
<button class="btn" @click="cancel">关闭</button>
</div>
<!-- 滚动截图失败气泡定位在选区上方多屏时也不会落在屏幕接缝 -->
<div
v-if="scrollToast"
class="scroll-toast"
:style="{
left: sel.x + sel.w / 2 + 'px',
top: Math.max(8, sel.y - 10) + 'px',
}"
>{{ scrollToast }}</div>
<!-- pick 阶段全屏淡遮罩无悬停窗口时有悬停窗口时由窗口框 box-shadow 暗化框外 -->
<div v-if="phase === 'pick' && !winHighlight" class="pick-mask" />
@@ -2103,8 +2287,12 @@ onUnmounted(() => {
@mousedown.stop="onRegionMouseDown"
@dblclick.stop="onRegionDblClick"
>
<div class="region-bg" :style="regionBgStyle" />
<canvas ref="canvasRef" class="anno-canvas" :width="physW" :height="physH" />
<div v-if="!scrollMode" class="region-bg" :style="regionBgStyle" />
<canvas v-if="!scrollMode" ref="canvasRef" class="anno-canvas" :width="physW" :height="physH" />
<!-- 滚动模式左上角实时显示已拼接分辨率与框选过程的尺寸标签同风格 -->
<span v-if="scrollMode" class="size-tag scroll-size-tag">
{{ scrollProgress ? `${scrollProgress.width} × ${scrollProgress.height}` : `${selPhys.w} × ${selPhys.h}` }}
</span>
<div class="region-border" />
<!-- 文字输入textarea 支持多行Enter 换行Ctrl+Enter 提交 -->
<textarea
@@ -2133,8 +2321,8 @@ onUnmounted(() => {
</template>
</div>
<!-- 缩放手柄已有标注时禁用缩放仅可移动 -->
<template v-if="phase === 'selected' && !hasAnnotations">
<!-- 缩放手柄已有标注时禁用缩放仅可移动滚动模式下隐藏 -->
<template v-if="phase === 'selected' && !hasAnnotations && !scrollMode">
<div
v-for="dir in HANDLES"
:key="dir"
@@ -2145,8 +2333,8 @@ onUnmounted(() => {
/>
</template>
<!-- 编辑栏单行布局工具栏右边框与选区右边框对齐复制/保存置于末尾 -->
<div v-if="showToolbar" ref="toolbarRef" class="toolbar" :style="toolbarPos" @mousedown.stop>
<!-- 编辑栏单行布局工具栏右边框与选区右边框对齐复制/保存置于末尾滚动模式下替换为滚动控制条 -->
<div v-if="showToolbar && !scrollMode" ref="toolbarRef" class="toolbar" :style="toolbarPos" @mousedown.stop>
<!-- 标注工具 -->
<div class="tb-group">
<Tooltip v-for="t in TOOLS" :key="t.value">
@@ -2159,27 +2347,24 @@ onUnmounted(() => {
<component :is="t.icon" class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t.label }}</TooltipContent>
<TooltipContent>{{ t.label }} ({{ t.key }})</TooltipContent>
</Tooltip>
</div>
<div class="tb-sep" />
<!-- 颜色色卡显示当前颜色点击打开选色板 -->
<!-- 颜色色卡显示当前颜色点击打开选色板
注意Popover 触发器不能套在 <Tooltip as-child> 两层 as-child 组合触发器会把
click onOpenToggle 合成掉导致点击无反应这里直接让 PopoverTrigger 绑按钮 -->
<Popover v-model:open="colorPickerOpen">
<Tooltip>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<button
class="color-badge"
:class="{ disabled: !colorEnabled }"
:style="{ '--swatch-color': effectiveColor }"
:disabled="!colorEnabled"
/>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>颜色</TooltipContent>
</Tooltip>
<PopoverTrigger as-child>
<button
class="color-badge"
:class="{ disabled: !colorEnabled }"
:style="{ '--swatch-color': effectiveColor }"
:disabled="!colorEnabled"
/>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="start">
<div class="color-popover">
<input
@@ -2205,20 +2390,15 @@ onUnmounted(() => {
</PopoverContent>
</Popover>
<!-- 粗细 badge点击打开 Slider -->
<!-- 粗细 badge点击打开 Slider同样去掉 Tooltip 嵌套避免合成掉 onOpenToggle -->
<Popover v-model:open="lineWidthPickerOpen">
<Tooltip>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<button
class="width-badge"
:class="{ disabled: !lineWidthEnabled }"
:disabled="!lineWidthEnabled"
>{{ effectiveLineWidth }}</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>粗细</TooltipContent>
</Tooltip>
<PopoverTrigger as-child>
<button
class="width-badge"
:class="{ disabled: !lineWidthEnabled }"
:disabled="!lineWidthEnabled"
>{{ effectiveLineWidth }}</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" align="start">
<div class="width-popover">
<div class="width-popover-header">
@@ -2279,7 +2459,7 @@ onUnmounted(() => {
<Undo2 class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>撤销</TooltipContent>
<TooltipContent>撤销 (Ctrl+Z)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
@@ -2287,7 +2467,7 @@ onUnmounted(() => {
<Redo2 class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>重做</TooltipContent>
<TooltipContent>重做 (Ctrl+Y)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
@@ -2295,7 +2475,7 @@ onUnmounted(() => {
<Eraser class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>清空标注</TooltipContent>
<TooltipContent>清空标注 (Ctrl+D)</TooltipContent>
</Tooltip>
</div>
@@ -2305,6 +2485,23 @@ onUnmounted(() => {
<div class="tb-sep" />
<!-- 滚动截图:自动滚动(自动向下拼接到底部) -->
<div class="tb-group">
<Tooltip>
<TooltipTrigger as-child>
<button
class="tb-icon"
@click="startScroll"
>
<ChevronsDown class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>滚动截图 (S)</TooltipContent>
</Tooltip>
</div>
<div class="tb-sep" />
<!-- 复制 / 保存(置末) -->
<div class="tb-group">
<Tooltip>
@@ -2313,7 +2510,7 @@ onUnmounted(() => {
<Save class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>保存到文件</TooltipContent>
<TooltipContent>保存到文件 (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
@@ -2326,6 +2523,15 @@ onUnmounted(() => {
</div>
</div>
<!-- 界面内滚动控制条:选区下方,显示模式与操作提示(实时分辨率在选区左上角),可停止(保留已拼)或取消(丢弃) -->
<div v-if="scrollMode" ref="toolbarRef" class="scroll-bar" :style="toolbarPos" @mousedown.stop>
<span class="sm-mode auto">自动</span>
<span class="sm-dot" />
<span class="sm-hint">平滑滚动拼接中 · Enter 停止 · Esc 取消</span>
<button class="sm-btn primary" @click="stopScroll">停止</button>
<button class="sm-btn ghost" @click="cancelScroll">取消</button>
</div>
<!-- 取色器放大镜:放大像素 + 中心色块 + 坐标 + 颜色值 + 提示 -->
<div v-show="magVisible" class="magnifier" :style="{ left: magX + 'px', top: magY + 'px' }">
<canvas ref="magCanvasRef" :width="MAG_W" :height="MAG_H" class="mag-canvas" />
@@ -2396,6 +2602,24 @@ onUnmounted(() => {
z-index: 20;
}
.hint.error { flex-direction: column; }
/* 滚动截图失败气泡:不占满全屏,定位在选区上方避免落在多屏接缝 */
.scroll-toast {
position: absolute;
transform: translate(-50%, -100%);
max-width: 70vw;
padding: 8px 14px;
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(239, 68, 68, 0.6);
color: #fecaca;
font-size: 13px;
line-height: 1.4;
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
z-index: 30;
pointer-events: none;
white-space: normal;
word-break: break-word;
}
.btn {
padding: 4px 12px;
background: var(--primary);
@@ -2642,6 +2866,60 @@ onUnmounted(() => {
white-space: nowrap;
}
/* 界面内滚动控制条(方案 B):与工具栏同锚点,位于选区下方 */
.scroll-bar {
position: absolute;
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px;
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
z-index: 30;
}
.sm-mode {
padding: 1px 8px;
border-radius: 999px;
font-size: 12px;
color: #fff;
white-space: nowrap;
}
.sm-mode.auto { background: #2563eb; }
.sm-mode.manual { background: #059669; }
/* 拼接进行中的呼吸点 */
.sm-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #22c55e;
animation: sm-pulse 1.1s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes sm-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.15); }
}
.sm-hint {
font-size: 12px;
color: var(--muted-foreground);
white-space: nowrap;
}
.sm-btn {
padding: 4px 14px;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.sm-btn.primary { background: var(--primary); color: var(--primary-foreground); }
.sm-btn.primary:hover { filter: brightness(0.95); }
.sm-btn.ghost { background: var(--accent); color: var(--accent-foreground); }
.sm-btn.ghost:hover { filter: brightness(0.97); }
/* 文字输入:textarea 支持多行,Enter 换行 */
.text-input {
position: absolute;
@@ -2732,4 +3010,10 @@ onUnmounted(() => {
white-space: pre-line;
word-break: keep-all;
}
/* 滚动模式左上角实时分辨率标签(.region-view overflow:hidden 会裁掉负偏移,故置于内部左上角) */
.scroll-size-tag {
top: 4px;
left: 4px;
z-index: 8;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
/** 滚动截图控制窗:覆盖层隐藏后弹出,实时展示拼接进度,完成/取消滚动截图。 */
import { onMounted, onUnmounted, ref } from 'vue'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { commands } from '@/lib/bindings'
import { EVENTS } from '@/lib/constants'
const win = getCurrentWindow()
// 模式来自 URL hash 参数(覆盖层创建窗口时携带):#screenshot-scroll?auto=1 自动,否则手动
const auto = ref(readAutoMode())
function readAutoMode(): boolean {
const qs = location.hash.split('?')[1] ?? ''
return new URLSearchParams(qs).get('auto') === '1'
}
const width = ref(0)
const height = ref(0)
const status = ref('')
/** 是否已收到最终事件(完成/取消),此后关闭窗口不再触发取消 */
let ended = false
let unlisten: UnlistenFn[] = []
onMounted(async () => {
status.value = auto.value ? '自动向下拼接中…' : '请滚动目标窗口,完成后点「完成」'
void win.show().catch(() => {})
// 不抢焦点:手动模式下用户需滚目标窗口,焦点应留在目标窗口
unlisten.push(
await listen<{ width: number; height: number }>(EVENTS.scrollProgress, (e) => {
width.value = e.payload.width
height.value = e.payload.height
}),
)
unlisten.push(
await listen(EVENTS.scrollComplete, () => {
ended = true
status.value = '已在历史中添加'
setTimeout(() => void win.close().catch(() => {}), 400)
}),
)
unlisten.push(
await listen(EVENTS.scrollCancelled, () => {
ended = true
void win.close().catch(() => {})
}),
)
})
onUnmounted(() => {
unlisten.forEach((fn) => fn())
// 用户直接关窗(未走完成/取消)→ 通知取消会话
if (!ended) {
commands.screenshotScrollCancel().catch(() => {})
}
})
function finish() {
status.value = '正在处理…'
void commands.screenshotScrollFinish().catch((e) => {
status.value = String(e)
})
}
function cancel() {
ended = true
void commands.screenshotScrollCancel().catch(() => {})
void win.close().catch(() => {})
}
</script>
<template>
<div class="scroll-panel">
<div class="scroll-header" data-tauri-drag-region>
<span class="title">滚动截图</span>
<span class="mode" :class="auto ? 'auto' : 'manual'">{{ auto ? '自动' : '手动' }}</span>
</div>
<div class="scroll-body">
<div class="size" v-if="height > 0">
<b>{{ width }}</b> × <b>{{ height }}</b>
</div>
<div class="size empty" v-else>等待内容</div>
<div class="status">{{ status }}</div>
<div class="hint" v-if="!auto">用鼠标滚轮向下滚动目标窗口</div>
</div>
<div class="scroll-actions">
<button class="btn ghost" @click="cancel">取消</button>
<button class="btn primary" @click="finish">完成</button>
</div>
</div>
</template>
<style scoped>
.scroll-panel {
width: 260px;
border-radius: 10px;
overflow: hidden;
background: var(--popover, #1e293b);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
color: #e2e8f0;
font-size: 13px;
user-select: none;
}
.scroll-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.06);
cursor: move;
}
.title {
font-weight: 600;
}
.mode {
padding: 1px 8px;
border-radius: 8px;
font-size: 11px;
}
.mode.auto {
background: #2563eb;
color: #fff;
}
.mode.manual {
background: #059669;
color: #fff;
}
.scroll-body {
padding: 14px 14px 6px;
}
.size {
font-size: 16px;
color: #fff;
}
.size.empty {
color: #94a3b8;
}
.status {
margin-top: 6px;
color: #cbd5e1;
line-height: 1.4;
min-height: 18px;
}
.hint {
margin-top: 4px;
color: #94a3b8;
font-size: 12px;
}
.scroll-actions {
display: flex;
gap: 8px;
padding: 10px 12px 12px;
}
.btn {
flex: 1;
padding: 7px 0;
border: none;
border-radius: 7px;
font-size: 13px;
cursor: pointer;
}
.btn.ghost {
background: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
}
.btn.ghost:hover {
background: rgba(255, 255, 255, 0.14);
}
.btn.primary {
background: #2563eb;
color: #fff;
}
.btn.primary:hover {
background: #1d4ed8;
}
</style>
+14 -9
View File
@@ -47,16 +47,21 @@ export interface ScreenshotBeginPayload {
}
// ===== 工具与选项 =====
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: '高亮' },
/** key = 工具快捷键(单字母,不区分大小写响应) */
export const TOOLS: { value: ToolType; icon: Component; label: string; key: string }[] = [
{ value: 'rect', icon: Square, label: '矩形', key: 'R' },
{ value: 'ellipse', icon: Circle, label: '椭圆', key: 'O' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头', key: 'A' },
{ value: 'number', icon: ListOrdered, label: '序号', key: 'N' },
{ value: 'pen', icon: Pencil, label: '画笔', key: 'P' },
{ value: 'text', icon: Type, label: '文字', key: 'T' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克', key: 'M' },
{ value: 'highlight', icon: Highlighter, label: '高亮', key: 'H' },
]
/** 快捷键(小写字母)→ 工具,覆盖层与编辑器共用 */
export const TOOL_KEYS: Partial<Record<string, ToolType>> = Object.fromEntries(
TOOLS.map((t) => [t.key.toLowerCase(), t.value]),
)
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]
+20 -94
View File
@@ -10,43 +10,32 @@ import { useAppStore, type Theme, type EffectType, type ModuleInfo } from '@/sto
import { useSearchStore } from '@/stores/searchStore'
import { useProcessStore } from '@/stores/processStore'
import { useDownloaderStore } from '@/stores/downloaderStore'
import { useMonitorStore } from '@/stores/monitorStore'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
import { getModuleIcon } from '@/modules/icons'
import { commands, type UpdateCheckResult } from '@/lib/bindings'
import { EVENTS } from '@/lib/constants'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { toast } from 'vue-sonner'
import { storeToRefs } from 'pinia'
import { useUpdaterStore } from '@/stores/updaterStore'
const appStore = useAppStore()
const searchStore = useSearchStore()
const processStore = useProcessStore()
// ===== 关于 / 更新 =====
/** 更新编排状态/进度提升到全局 store,切模块后下载进度与 need_stop 等待不丢失 */
const updater = useUpdaterStore()
const { appUpdating, kernelUpdating, progress, downloadCancellable, kernelDownloadCancellable, thinghkConfirmState } = storeToRefs(updater)
/** 更新进度事件载荷(与 Rust UpdateProgress 对应) */
interface UpdateProgress {
stage: string
percent: number
downloadedBytes: number
totalBytes: number | null
message: string
}
// ===== 关于 / 更新 =====
/** 当前应用版本(启动时读取) */
const currentVersion = ref('')
/** 检查更新的结果 */
const updateResult = ref<UpdateCheckResult | null>(null)
const checking = ref(false)
/** 应用本体更新中 */
const appUpdating = ref(false)
/** ThingHK 内核更新中 */
const kernelUpdating = ref(false)
const progress = ref<UpdateProgress | null>(null)
let progressUnlisten: UnlistenFn | null = null
const installTypeText = computed(() =>
updateResult.value?.installType === 'installed' ? '安装版' : '便携版',
@@ -81,8 +70,6 @@ const checkUpdate = async () => {
const downloaderStore = useDownloaderStore()
/** 下载中显示取消按钮(下载完成进入 applying 阶段后不可取消) */
const downloadCancellable = ref(false)
/** 取消下载的唤醒回调(由等待 Promise 设置) */
let cancelAppDownload: (() => void) | null = null
@@ -219,63 +206,17 @@ const cancelUpdateDownload = async () => {
/** 更新 ThingHK 内核:下载模块下载 → apply 命令 need_stop 等待确认 → 停止内核 → 解压替换。
* 与代理模块 mihomo 内核更新同模式。 */
const thinghkExists = ref(false)
/** 需要停止监控内核的确认弹窗状态(need_stop 阶段由后端 event 触发) */
const thinghkConfirmState = ref<{ open: boolean; wasRunning: boolean; busy: boolean; resolved: boolean }>({
open: false,
wasRunning: false,
busy: false,
resolved: false,
})
const monitorStore = useMonitorStore()
/** 下载中可取消(沿用应用更新下载的取消交互) */
const kernelDownloadCancellable = ref(false)
/** 取消 ThingHK 内核包下载的唤醒回调 */
let cancelKernelDownload: (() => void) | null = null
/** 停止 Kernel 确认弹窗中处理中标记(防止重复触发 */
let handlingThinghkNeedStop = false
const handleThinghkNeedStop = () => {
if (handlingThinghkNeedStop) return
handlingThinghkNeedStop = true
try {
// 若程序被最小化/隐藏到后台,先弹到前台再显示确认框
invoke('quickpanel_focus_main_window').catch(() => { /* 忽略 */ })
const wasRunning = monitorStore.status?.running ?? false
thinghkConfirmState.value = { open: true, wasRunning, busy: false, resolved: false }
} finally {
handlingThinghkNeedStop = false
}
}
/** 确认停止内核并唤醒后端 apply 继续解压替换 */
const onThinghkNeedStopConfirm = async () => {
const s = thinghkConfirmState.value
if (s.resolved) return
// 置 resolved 防止 AlertDialog 的 update:open(false) 兜底逻辑把确认误判为取消
s.resolved = true
s.busy = true
try {
if (s.wasRunning) {
await monitorStore.stop()
}
await invoke('update_thinghk_confirm')
} catch (e) {
toast.error('停止监控内核失败', { description: String(e) })
// 停止失败则中止更新,避免替换阶段因 exe 占用而报错
try { await invoke('update_thinghk_cancel') } catch { /* 忽略 */ }
} finally {
s.busy = false
s.open = false
}
/** 确认停止内核并唤醒后端 apply 继续解压替换(委托给全局 updater store */
const onThinghkNeedStopConfirm = () => {
void updater.confirmNeedStop()
}
/** 取消 ThingHK 内核更新(中止后端等待中的 apply 流程,zip 保留便于重试) */
const onThinghkNeedStopCancel = () => {
const s = thinghkConfirmState.value
if (s.resolved) return
s.resolved = true
s.open = false
invoke('update_thinghk_cancel').catch(() => { /* 忽略 */ })
updater.cancelNeedStop()
}
/** AlertDialog 关闭事件的兜底判定:cancel/action 点击会先 update:open(false) 再 click
@@ -284,7 +225,7 @@ const onThinghkNeedStopOpenChange = (open: boolean) => {
const s = thinghkConfirmState.value
if (!open && !s.resolved) {
setTimeout(() => {
if (!thinghkConfirmState.value.resolved) onThinghkNeedStopCancel()
if (!thinghkConfirmState.value.resolved) updater.cancelNeedStop()
}, 0)
}
thinghkConfirmState.value.open = open
@@ -299,7 +240,12 @@ const cancelKernelUpdateDownload = () => {
/** 更新 ThingHK 内核 */
const updateThinghkKernel = async () => {
if (kernelUpdating.value) return
const result = updateResult.value
// 未先"检查更新"时自动检查一次,避免点击"更新内核"无反应
let result = updateResult.value
if (!result) {
await checkUpdate()
result = updateResult.value
}
if (!result) return
// 从 release assets 中定位 ThingHK 内核包(zip);找不到则提示手动下载
const asset = result.assets.find(a => {
@@ -422,23 +368,8 @@ const systemDark = computed(() => appStore.systemDark)
onMounted(() => {
loadAppInfo()
// 监听更新进度事件(应用更新与 ThingHK 内核更新共用)
listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
progress.value = e.payload
const p = e.payload
if (p.stage === 'need_stop') {
// ThingHK 内核更新:解压替换前需停止监控内核,弹窗确认(若在后台自动弹到前台)
handleThinghkNeedStop()
} else if (p.stage === 'done') {
kernelUpdating.value = false
progress.value = null
} else if (p.stage === 'error') {
// ThingHK 内核更新失败(后端 emit);应用更新失败走命令 reject 路径
kernelUpdating.value = false
}
}).then((fn) => {
progressUnlisten = fn
})
// 更新进度事件由全局 updater store 订阅(切换模块仍持续),组件只负责展示
void updater.subscribe()
searchStore.registerAction('settings', 0, () => appStore.setTheme('light'))
searchStore.registerAction('settings', 1, () => appStore.setTheme('dark'))
searchStore.registerAction('settings', 2, () => appStore.setTheme('system'))
@@ -456,11 +387,6 @@ onMounted(() => {
processStore.refreshAll().catch(() => { /* 忽略:后端可能未就绪 */ })
})
onUnmounted(() => {
progressUnlisten?.()
progressUnlisten = null
})
const themes: Array<{ id: Theme; name: string; color: string; icon: typeof Sun }> = [
{ id: 'light', name: '浅色模式', color: '#f8fafc', icon: Sun },
{ id: 'dark', name: '深色模式', color: '#1e293b', icon: Moon },
+22 -1
View File
@@ -311,6 +311,8 @@ export const useMonitorStore = defineStore('monitor', () => {
const snapshot = ref<SensorSnapshot | null>(null)
const kernelInfo = ref<MonitorKernelInfo | null>(null)
const errorMsg = ref<string | null>(null)
/** 提权运行但 PawnIO 驱动缺失(CPU 温度/功耗大概率无法读取) */
const pawnIoMissing = ref(false)
/** 累计收到的 monitor-data 事件数,用于诊断与"已连接"判定 */
const eventCount = ref(0)
/** 最近一次收到 monitor-data 的时间戳(ms */
@@ -690,10 +692,12 @@ export const useMonitorStore = defineStore('monitor', () => {
// OSD 开启时同步推送(合并到每帧一次,避免与网速事件重复推送)
if (osdConfig.value.overlayEnabled) scheduleOsdPush()
}))
unlistenFns.push(await listen(EVENTS.monitorReady, () => {
unlistenFns.push(await listen<{ isAdmin?: boolean; pawnIoInstalled?: boolean }>(EVENTS.monitorReady, (e) => {
refreshStatus()
// Kernel 就绪后主动拉取一次快照,避免等待 SSE 首事件导致 UI 空白
fetchSnapshot()
// PawnIO 驱动缺失检测:提权运行但仍缺驱动 → CPU 温度/功耗大概率无法读取
pawnIoMissing.value = e.payload?.isAdmin === true && e.payload?.pawnIoInstalled === false
}))
unlistenFns.push(await listen(EVENTS.monitorLoading, () => {
// 后端正在等待 Kernel ready,刷新状态以反映 running=true
@@ -1154,6 +1158,22 @@ export const useMonitorStore = defineStore('monitor', () => {
void pushOsdState()
})
osdEventUnlisteners.push(unlistenReq)
// 监听游戏全屏事件:前台出现全屏应用(游戏)时隐藏 OSD,
// 避免透明置顶悬浮窗占用 DWM 合成路径导致游戏掉帧;退出全屏后恢复。
const unlistenGameActive = await listen(EVENTS.osdGameActive, async () => {
gameFullscreen.value = true
if (osdConfig.value.overlayEnabled && osdConfig.value.gameAutoHide) {
await hideOverlayWindow()
}
})
osdEventUnlisteners.push(unlistenGameActive)
const unlistenGameInactive = await listen(EVENTS.osdGameInactive, async () => {
gameFullscreen.value = false
if (osdConfig.value.overlayEnabled && osdConfig.value.gameAutoHide) {
await ensureOverlayWindow()
}
})
osdEventUnlisteners.push(unlistenGameInactive)
}
/** initOsd 幂等守卫:监听/配置 watcher 只注册一次(App 启动 + 模块挂载均会调用) */
@@ -1277,6 +1297,7 @@ export const useMonitorStore = defineStore('monitor', () => {
snapshot,
kernelInfo,
errorMsg,
pawnIoMissing,
eventCount,
lastEventTime,
starting,
+71 -3
View File
@@ -253,8 +253,48 @@ export const useScreenshotStore = defineStore('screenshot', () => {
await ensureOverlay()
}
async function openEditor() {
new WebviewWindow(`screenshot-editor-${Date.now()}`, {
// ===== 常驻截图编辑器窗口 =====
const EDITOR_LABEL = WINDOWS.screenshotEditor
let editorWin: WebviewWindow | null = null
let editorReadyUnlisten: UnlistenFn | null = null
let editorReadyListenerInit = false
let editorReadyResolve: (() => void) | null = null
let editorReadyPromise: Promise<void> | null = null
function resetEditorReady() {
editorReadyPromise = new Promise<void>((resolve) => {
editorReadyResolve = resolve
})
}
async function waitEditorReady() {
await Promise.race([
editorReadyPromise,
new Promise<void>((r) => setTimeout(r, 5000)),
])
}
async function ensureEditorListener() {
if (editorReadyListenerInit) return
editorReadyListenerInit = true
resetEditorReady()
editorReadyUnlisten = await listen(EVENTS.screenshotEditorReady, () => {
editorReadyResolve?.()
})
}
/** 创建常驻编辑器窗口(隐藏,首次打开时创建一次,之后 show 复用不重建 WebView */
async function ensureEditorWindow() {
if (editorWin) {
const existing = await WebviewWindow.getByLabel(EDITOR_LABEL).catch(() => null)
if (existing) {
editorWin = existing
return
}
editorWin = null
}
resetEditorReady()
editorWin = new WebviewWindow(EDITOR_LABEL, {
url: 'index.html#screenshot-editor',
title: '截图编辑器',
width: 960,
@@ -268,10 +308,18 @@ export const useScreenshotStore = defineStore('screenshot', () => {
resizable: true,
shadow: true,
focus: true,
visible: true,
visible: false,
})
}
/** 打开编辑器:确保常驻窗口就绪后通知其加载编辑器图片槽中的新图(图由 Rust 侧直接写入) */
async function openEditor() {
await ensureEditorListener()
await ensureEditorWindow()
await waitEditorReady()
await emit(EVENTS.screenshotEditorLoad)
}
// ===== 历史 / 导出 =====
/** 由完整 PNG base64 生成缩略图 data URLcanvas 缩放至 ~256px 宽,JPEG 压缩) */
function makeThumb(pngBase64: string, width: number, height: number): Promise<string> {
@@ -468,6 +516,17 @@ export const useScreenshotStore = defineStore('screenshot', () => {
})
}
// ===== 滚动截图完成 → 进编辑器 =====
/** 滚动截图完成的监听(覆盖层通知 → 打开常驻编辑器;PNG 原始字节已由 Rust 写入编辑器图片槽) */
let scrollEditorUnlisten: UnlistenFn | null = null
async function initScrollEditorListener() {
if (scrollEditorUnlisten) return
scrollEditorUnlisten = await listen(EVENTS.scrollToEditor, () => {
void openEditor()
})
}
// ===== 全局快捷键 =====
/** 监听 Rust 侧 emit 的 'screenshot-shortcut' 事件(快捷键按下时触发) */
async function initShortcutListener() {
@@ -627,6 +686,14 @@ export const useScreenshotStore = defineStore('screenshot', () => {
pinReadyUnlisten()
pinReadyUnlisten = null
}
if (editorReadyUnlisten) {
editorReadyUnlisten()
editorReadyUnlisten = null
}
if (scrollEditorUnlisten) {
scrollEditorUnlisten()
scrollEditorUnlisten = null
}
}
function destroyShortcutListener() {
@@ -662,6 +729,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
setPinShortcut,
initExportListener,
destroyExportListener,
initScrollEditorListener,
initShortcutListener,
initShortcutRegistration,
destroyShortcutListener,
+163
View File
@@ -0,0 +1,163 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { EVENTS } from '@/lib/constants'
import { useMonitorStore } from '@/stores/monitorStore'
import { createLogger } from '@/lib/logger'
const logger = createLogger('updater')
/** 更新进度事件载荷(与 Rust UpdateProgress 对应) */
export interface UpdateProgress {
stage: string
percent: number
downloadedBytes: number
totalBytes: number | null
message: string
}
/**
* / ThingHK
*
* "need_stop 确认" store
* GeneralSettings need_stop
* done "切回来进度丢失 / 更新卡死 / 不重启内核"
*/
export const useUpdaterStore = defineStore('updater', () => {
/** 应用本体更新中 */
const appUpdating = ref(false)
/** ThingHK 内核更新中 */
const kernelUpdating = ref(false)
/** 更新进度(下载/解压/need_stop/done 共用一条进度展示) */
const progress = ref<UpdateProgress | null>(null)
/** 应用更新下载中可取消(下载完成进入 applying 后不可取消) */
const downloadCancellable = ref(false)
/** ThingHK 内核包下载中可取消 */
const kernelDownloadCancellable = ref(false)
/** 停止监控内核的确认弹窗状态(need_stop 阶段由后端 event 触发) */
const thinghkConfirmState = ref<{
open: boolean
wasRunning: boolean
busy: boolean
resolved: boolean
}>({
open: false,
wasRunning: false,
busy: false,
resolved: false,
})
let subscribed = false
const unlisteners: UnlistenFn[] = []
let handlingNeedStop = false
/** 若应用被最小化/隐藏到后台,先弹到前台再显示确认框 */
const focusMain = () => {
invoke('quickpanel_focus_main_window').catch(() => { /* 忽略 */ })
}
/** need_stop:记录内核是否在运行,弹出确认框等待用户停止并继续 */
function handleNeedStop() {
if (handlingNeedStop) return
handlingNeedStop = true
try {
focusMain()
const monitor = useMonitorStore()
const wasRunning = monitor.status?.running ?? false
thinghkConfirmState.value = { open: true, wasRunning, busy: false, resolved: false }
} finally {
handlingNeedStop = false
}
}
/** 全局订阅 UPDATE_PROGRESSneed_stop / done / error 与组件挂载无关,切换标签不中断更新 */
async function subscribe() {
if (subscribed) return
subscribed = true
try {
unlisteners.push(
await listen<UpdateProgress>(EVENTS.updateProgress, (e) => {
progress.value = e.payload
const p = e.payload
if (p.stage === 'need_stop') {
handleNeedStop()
} else if (p.stage === 'done') {
const wasRunning = thinghkConfirmState.value.wasRunning
kernelUpdating.value = false
progress.value = null
// 更新前内核在运行 → 替换完成后恢复(重启内核),保持内核状态
if (wasRunning) {
const monitor = useMonitorStore()
void monitor.start()
}
} else if (p.stage === 'error') {
kernelUpdating.value = false
progress.value = null
}
}),
)
} catch (e) {
logger.error('[updater] 订阅更新进度失败: ' + e)
}
}
/** 确认停止内核并唤醒后端 apply 继续解压替换 */
async function confirmNeedStop() {
const s = thinghkConfirmState.value
if (s.resolved) return
s.resolved = true
s.busy = true
try {
if (s.wasRunning) {
const monitor = useMonitorStore()
await monitor.stop()
}
await invoke('update_thinghk_confirm')
} catch (e) {
logger.error('[updater] 停止监控内核失败: ' + e)
// 停止失败则中止更新,避免替换阶段因 exe 占用而报错
try {
await invoke('update_thinghk_cancel')
} catch { /* 忽略 */ }
} finally {
s.busy = false
s.open = false
}
}
/** 取消 ThingHK 内核更新(中止后端等待中的 apply 流程) */
function cancelNeedStop() {
const s = thinghkConfirmState.value
if (s.resolved) return
s.resolved = true
s.open = false
invoke('update_thinghk_cancel').catch(() => { /* 忽略 */ })
}
/** 复位全局更新状态(订阅与状态一并清理,供测试/异常恢复用) */
function reset() {
appUpdating.value = false
kernelUpdating.value = false
progress.value = null
downloadCancellable.value = false
kernelDownloadCancellable.value = false
thinghkConfirmState.value = { open: false, wasRunning: false, busy: false, resolved: false }
subscribed = false
unlisteners.forEach((fn) => fn())
unlisteners.length = 0
}
return {
appUpdating,
kernelUpdating,
progress,
downloadCancellable,
kernelDownloadCancellable,
thinghkConfirmState,
subscribe,
confirmNeedStop,
cancelNeedStop,
reset,
}
})