截图模块初始化
This commit is contained in:
@@ -3,12 +3,12 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
||||
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
|
||||
ChevronLeft, ChevronRight,
|
||||
} from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useClipboardStore, type ClipboardItem, type ClipboardKind } from '@/stores/clipboardStore'
|
||||
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
|
||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
|
||||
} from '@/components/ui/pagination'
|
||||
|
||||
const store = useClipboardStore()
|
||||
|
||||
@@ -66,7 +69,7 @@ const gotoPage = async (p: number) => {
|
||||
// 详情弹窗
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<ReturnType<typeof Object> & { content?: string | null; imageBase64?: string | null; kind?: string } | null>(null)
|
||||
const detail = ref<ClipboardItemDetail | null>(null)
|
||||
const openDetail = async (item: ClipboardItem) => {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
@@ -76,6 +79,15 @@ const openDetail = async (item: ClipboardItem) => {
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
/** 根据 base64 前缀判断图片 MIME 类型(dib_to_png 可能直接透传 PNG/JPEG) */
|
||||
const imageSrc = computed(() => {
|
||||
const b64 = detail.value?.imageBase64
|
||||
if (!b64) return ''
|
||||
// PNG base64 以 iVBORw0KGgo 开头,JPEG 以 /9j/ 开头
|
||||
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
|
||||
return `data:${mime};base64,${b64}`
|
||||
})
|
||||
|
||||
// 清空确认
|
||||
const clearOpen = ref(false)
|
||||
|
||||
@@ -226,7 +238,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<Badge variant="secondary">
|
||||
<Loader2 v-if="store.status.running" class="h-3 w-3 mr-1 animate-spin" />
|
||||
{{ store.historyTotal }} 条
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
|
||||
@@ -234,10 +245,40 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
|
||||
<!-- 分页(置于列表上方,靠左显示) -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-start pb-2 shrink-0">
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:page="currentPage"
|
||||
:total="store.historyTotal"
|
||||
:items-per-page="PAGE_SIZE"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="gotoPage"
|
||||
class="justify-start"
|
||||
>
|
||||
<PaginationContent v-slot="{ items }" class="gap-1">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
:is-active="item.value === page"
|
||||
size="icon"
|
||||
class="size-7 text-xs"
|
||||
>
|
||||
{{ item.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else class="size-7" />
|
||||
</template>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
|
||||
<ScrollArea class="flex-1 min-h-0">
|
||||
<div class="space-y-1.5 pr-2">
|
||||
<div
|
||||
v-if="!historyList.length"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
class="flex flex-col items-center justify-center text-muted-foreground py-12"
|
||||
>
|
||||
<ClipboardList class="h-12 w-12 mb-3 opacity-40" />
|
||||
<p class="text-sm">暂无历史记录,复制内容后将自动收录</p>
|
||||
@@ -270,18 +311,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-3 shrink-0">
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 固定 -->
|
||||
@@ -428,11 +459,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<template v-else-if="detail">
|
||||
<img
|
||||
v-if="detail.kind === 'image' && detail.imageBase64"
|
||||
:src="`data:image/png;base64,${detail.imageBase64}`"
|
||||
v-if="detail.kind === 'image' && imageSrc"
|
||||
:src="imageSrc"
|
||||
class="max-w-full max-h-[60vh] mx-auto rounded"
|
||||
alt="剪贴板图片"
|
||||
/>
|
||||
<p v-else-if="detail.kind === 'image'" class="text-sm text-muted-foreground text-center py-8">
|
||||
图片预览不可用
|
||||
</p>
|
||||
<pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre>
|
||||
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
|
||||
<li
|
||||
|
||||
@@ -6,9 +6,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { Effect, EffectState } from '@tauri-apps/api/window'
|
||||
import {
|
||||
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
||||
FileText, Files, Loader2, ChevronLeft, ChevronRight,
|
||||
FileText, Files, Loader2,
|
||||
} from '@lucide/vue'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
|
||||
} from '@/components/ui/pagination'
|
||||
|
||||
// ===== 与 Rust 端对应的数据结构(camelCase) =====
|
||||
type ClipboardKind = 'text' | 'image' | 'files'
|
||||
@@ -123,10 +127,14 @@ function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
|
||||
cancelHoverTimer()
|
||||
previewVisible.value = false
|
||||
scrollSelectedIntoView()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||
cancelHoverTimer()
|
||||
previewVisible.value = false
|
||||
scrollSelectedIntoView()
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -169,6 +177,62 @@ const formatTime = (ms: number) => {
|
||||
|
||||
const hasItems = computed(() => items.value.length > 0)
|
||||
|
||||
// ===== 图片悬停预览(悬停 100ms 后显示缩略图) =====
|
||||
const previewSrc = ref('')
|
||||
const previewVisible = ref(false)
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// 缓存已加载的图片 id → dataUrl,避免重复请求
|
||||
const imageCache = new Map<number, string>()
|
||||
|
||||
/** 根据 base64 前缀判断 MIME 类型 */
|
||||
function buildImageDataUrl(b64: string): string {
|
||||
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
|
||||
return `data:${mime};base64,${b64}`
|
||||
}
|
||||
|
||||
async function onItemHover(idx: number, item: ClipboardItem) {
|
||||
selectedIndex.value = idx
|
||||
// 仅图片类型触发预览
|
||||
if (item.kind !== 'image') {
|
||||
cancelHoverTimer()
|
||||
previewVisible.value = false
|
||||
return
|
||||
}
|
||||
// 先取消之前的定时器和预览
|
||||
cancelHoverTimer()
|
||||
// 100ms 后加载并显示(快速响应悬停意图)
|
||||
hoverTimer = setTimeout(async () => {
|
||||
try {
|
||||
let src = imageCache.get(item.id)
|
||||
if (!src) {
|
||||
const detail = await invoke<{ imageBase64: string | null } | null>('clipboard_get_item', { id: item.id })
|
||||
if (detail?.imageBase64) {
|
||||
src = buildImageDataUrl(detail.imageBase64)
|
||||
imageCache.set(item.id, src)
|
||||
}
|
||||
}
|
||||
if (src) {
|
||||
previewSrc.value = src
|
||||
previewVisible.value = true
|
||||
}
|
||||
} catch {
|
||||
/* 忽略加载失败 */
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function cancelHoverTimer() {
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer)
|
||||
hoverTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function onItemLeave() {
|
||||
cancelHoverTimer()
|
||||
previewVisible.value = false
|
||||
}
|
||||
|
||||
// ===== 主题应用(与主应用同步) =====
|
||||
/** 从 localStorage 读取主应用的主题设置 */
|
||||
function readMainTheme(): { theme: string; effect: string } {
|
||||
@@ -271,6 +335,10 @@ onMounted(async () => {
|
||||
await applyTheme()
|
||||
searchQuery.value = ''
|
||||
currentPage.value = 1
|
||||
// 重置预览状态,清空缓存避免历史图片占用内存
|
||||
cancelHoverTimer()
|
||||
previewVisible.value = false
|
||||
imageCache.clear()
|
||||
await loadData()
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
@@ -290,74 +358,102 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelHoverTimer()
|
||||
unlistenFns.forEach((fn) => fn())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="popup-header shrink-0">
|
||||
<div class="search-wrap">
|
||||
<Search class="h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
<!-- 搜索栏(与剪切板主页统一样式) -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索剪贴板历史..."
|
||||
class="search-input"
|
||||
class="pl-8 text-sm"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<span class="popup-count">{{ total }} 条</span>
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- 图片悬停预览浮层 -->
|
||||
<Transition name="popup-preview">
|
||||
<div v-if="previewVisible && previewSrc" class="popup-preview">
|
||||
<img :src="previewSrc" alt="预览" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ScrollArea class="popup-list flex-1 min-h-0">
|
||||
<div v-if="loading && !hasItems" class="popup-empty">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else-if="!hasItems" class="popup-empty">
|
||||
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
class="popup-item"
|
||||
:class="{ 'popup-item-selected': idx === selectedIndex }"
|
||||
@click="selectAndPaste(item)"
|
||||
@mouseenter="selectedIndex = idx"
|
||||
>
|
||||
<component :is="kindIcon(item.kind)" class="popup-item-icon" />
|
||||
<div class="popup-item-body">
|
||||
<p class="popup-item-preview">{{ item.preview }}</p>
|
||||
<div class="popup-item-meta">
|
||||
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
|
||||
<span>{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="space-y-1.5 p-2">
|
||||
<div v-if="loading && !hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 class="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
<div class="popup-item-actions">
|
||||
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)">
|
||||
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="popup-action-btn hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div v-else-if="!hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
|
||||
<p class="text-sm">
|
||||
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
class="popup-item group"
|
||||
:class="{ 'popup-item-selected': idx === selectedIndex }"
|
||||
@click="selectAndPaste(item)"
|
||||
@mouseenter="onItemHover(idx, item)"
|
||||
@mouseleave="onItemLeave"
|
||||
>
|
||||
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||||
<span class="popup-item-kind px-1.5 py-0 text-[10px] border rounded-sm" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
|
||||
<span>{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popup-item-actions shrink-0">
|
||||
<button class="popup-action-btn h-7 w-7" title="固定" @click="togglePin(item, $event)">
|
||||
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button class="popup-action-btn h-7 w-7 hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="popup-pagination">
|
||||
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<!-- 分页(与剪切板历史统一样式) -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-1 px-2 py-1 border-t border-border">
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:page="currentPage"
|
||||
:total="total"
|
||||
:items-per-page="PAGE_SIZE"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="gotoPage"
|
||||
>
|
||||
<PaginationContent v-slot="{ items: pageItems }" class="gap-1">
|
||||
<template v-for="(item, index) in pageItems" :key="index">
|
||||
<PaginationItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
:is-active="item.value === page"
|
||||
size="icon"
|
||||
class="size-7 text-xs"
|
||||
>
|
||||
{{ item.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else class="size-7" />
|
||||
</template>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
@@ -377,49 +473,12 @@ onUnmounted(() => {
|
||||
color: var(--foreground);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 搜索栏 */
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--input);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.popup-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
.popup-list {
|
||||
padding: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
|
||||
@@ -427,66 +486,26 @@ onUnmounted(() => {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 条目卡片样式(与主界面 Card 统一) */
|
||||
.popup-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
/* 预留边框空间,避免 hover/选中时布局抖动 */
|
||||
border: 1px solid transparent;
|
||||
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */
|
||||
transition: background-color 0.1s, border-color 0.1s;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
transition: box-shadow 0.2s, background-color 0.1s;
|
||||
}
|
||||
|
||||
/* hover 使用 shadow(同主界面 hover:shadow-md),键盘选中保留轻微高亮 */
|
||||
.popup-item:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* 鼠标悬停和键盘选中统一使用 accent 高亮 + 反色边框增强提醒 */
|
||||
.popup-item:hover,
|
||||
.popup-item-selected {
|
||||
background: var(--accent);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
|
||||
.popup-item-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
color: var(--muted-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popup-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.popup-item-preview {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.popup-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.popup-item-kind {
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* 类型 badge 配色(与主界面一致) */
|
||||
@@ -522,7 +541,6 @@ onUnmounted(() => {
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 选中项和悬停项都显示操作按钮 */
|
||||
@@ -535,27 +553,20 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--muted-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted-foreground);
|
||||
transition: background-color 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.popup-action-btn:hover {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.popup-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
/* 空状态(使用 Tailwind 类,无需额外 CSS) */
|
||||
|
||||
/* 底部 */
|
||||
.popup-footer {
|
||||
@@ -568,42 +579,37 @@ onUnmounted(() => {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.popup-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.popup-page-btn {
|
||||
background: transparent;
|
||||
/* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
|
||||
.popup-preview {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
right: 10px;
|
||||
z-index: 100;
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
border-radius: 4px;
|
||||
color: var(--foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
background: var(--popover, var(--background));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.popup-page-btn:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
.popup-preview img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.popup-page-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
/* 预览浮层淡入淡出 */
|
||||
.popup-preview-enter-active,
|
||||
.popup-preview-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.popup-page-info {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
.popup-preview-enter-from,
|
||||
.popup-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.popup-footer kbd {
|
||||
|
||||
@@ -1,24 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { Camera } from '@lucide/vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
Camera, Square, AppWindow, Maximize, Copy, Save, Trash2, Image as ImageIcon, Loader2,
|
||||
} from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useScreenshotStore, type CaptureMode, type RecentCapture } from '@/stores/screenshotStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
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'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const store = useScreenshotStore()
|
||||
|
||||
const activeTab = ref('capture')
|
||||
const tabsListRef = useModuleTabs(activeTab, [
|
||||
{ value: 'capture', label: '截图' },
|
||||
{ value: 'history', label: '历史' },
|
||||
])
|
||||
|
||||
const captureActions: { mode: CaptureMode; icon: typeof Square; label: string; desc: string }[] = [
|
||||
{ mode: 'region', icon: Square, label: '区域截图', desc: '拖动选择屏幕任意区域' },
|
||||
{ mode: 'window', icon: AppWindow, label: '窗口截图', desc: '点击捕获指定窗口' },
|
||||
{ mode: 'fullscreen', icon: Maximize, label: '全屏截图', desc: '直接捕获整个虚拟屏' },
|
||||
]
|
||||
|
||||
function formatTime(t: number): string {
|
||||
const d = new Date(t)
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${hh}:${mm}:${ss}`
|
||||
}
|
||||
|
||||
function thumbSrc(item: RecentCapture): string {
|
||||
return `data:image/png;base64,${item.pngBase64}`
|
||||
}
|
||||
|
||||
async function handleCapture(mode: CaptureMode) {
|
||||
await store.startCapture(mode)
|
||||
}
|
||||
|
||||
async function handleCopy(item: RecentCapture) {
|
||||
await store.copyImage(item.pngBase64)
|
||||
}
|
||||
|
||||
async function handleSave(item: RecentCapture) {
|
||||
await store.saveImage(item.pngBase64)
|
||||
}
|
||||
|
||||
function handleDelete(item: RecentCapture) {
|
||||
const idx = store.recent.findIndex(r => r.id === item.id)
|
||||
if (idx >= 0) store.recent.splice(idx, 1)
|
||||
toast.success('已从历史移除')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.destroyExportListener()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full p-6 overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Camera class="h-5 w-5 text-primary" />
|
||||
截图模块
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<Camera class="h-16 w-16 mb-4 opacity-50" />
|
||||
<p>截图功能开发中...</p>
|
||||
<p class="text-sm mt-2">支持区域截图、窗口截图、全屏截图、滚动截图等功能</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="h-full overflow-hidden">
|
||||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
||||
<div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="capture">截图</TabsTrigger>
|
||||
<TabsTrigger value="history">
|
||||
历史
|
||||
<span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
|
||||
({{ store.recent.length }})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<!-- 截图触发 -->
|
||||
<TabsContent value="capture" class="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea class="h-full">
|
||||
<div class="p-4 pt-0 space-y-4">
|
||||
<!-- 截图模式卡片 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card
|
||||
v-for="action in captureActions"
|
||||
:key="action.mode"
|
||||
class="cursor-pointer transition-colors hover:border-primary/50 hover:bg-accent/50"
|
||||
:class="{ 'pointer-events-none opacity-60': store.capturing }"
|
||||
@click="handleCapture(action.mode)"
|
||||
>
|
||||
<CardContent class="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-primary/10 text-primary">
|
||||
<component :is="action.icon" class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="font-medium">{{ action.label }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ action.desc }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 截图中提示 -->
|
||||
<Card v-if="store.capturing">
|
||||
<CardContent class="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-primary" />
|
||||
<span>正在捕获屏幕…</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 说明 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm flex items-center gap-2">
|
||||
<Camera class="h-4 w-4 text-primary" />
|
||||
使用说明
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-sm text-muted-foreground space-y-1.5">
|
||||
<p>· <span class="text-foreground">区域截图</span>:进入覆盖层后拖动鼠标选择区域,松开后可编辑、复制或保存。</p>
|
||||
<p>· <span class="text-foreground">窗口截图</span>:移动鼠标高亮目标窗口,点击即可捕获该窗口。</p>
|
||||
<p>· <span class="text-foreground">全屏截图</span>:直接捕获所有显示器拼接画面并进入编辑器。</p>
|
||||
<p>· 选区/编辑器中按 <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 取消。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<TabsContent value="history" class="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea class="h-full">
|
||||
<div class="p-4 pt-0">
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="store.recent.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-muted-foreground gap-3"
|
||||
>
|
||||
<ImageIcon class="h-12 w-12 opacity-40" />
|
||||
<p>暂无截图历史</p>
|
||||
<p class="text-xs">截图并导出后会显示在这里</p>
|
||||
</div>
|
||||
|
||||
<!-- 历史网格 -->
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="item in store.recent"
|
||||
:key="item.id"
|
||||
class="group relative rounded-lg border bg-muted/30 overflow-hidden"
|
||||
>
|
||||
<!-- 缩略图 -->
|
||||
<div class="aspect-video flex items-center justify-center bg-zinc-900">
|
||||
<img
|
||||
:src="thumbSrc(item)"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
alt="截图"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 信息 -->
|
||||
<div class="px-2 py-1.5 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ formatTime(item.time) }}</span>
|
||||
<span>{{ item.width }}×{{ item.height }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮操作 -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
title="复制到剪贴板"
|
||||
@click.stop="handleCopy(item)"
|
||||
>
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
title="保存到文件"
|
||||
@click.stop="handleSave(item)"
|
||||
>
|
||||
<Save class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
title="从历史移除"
|
||||
@click.stop="handleDelete(item)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
+238
-132
@@ -2,17 +2,16 @@
|
||||
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, LogicalSize, PhysicalPosition } from '@tauri-apps/api/window'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { Effect, EffectState } from '@tauri-apps/api/window'
|
||||
import {
|
||||
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
|
||||
Settings, LogOut,
|
||||
Settings, LogOut, Camera,
|
||||
} from '@lucide/vue'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
// ===== 类型定义(与 Rust 端对应) =====
|
||||
interface ProxyNodeInfo {
|
||||
name: string
|
||||
delay: number | null
|
||||
@@ -20,38 +19,52 @@ interface ProxyNodeInfo {
|
||||
}
|
||||
interface TrayMenuState {
|
||||
proxyRunning: boolean
|
||||
systemProxy: boolean
|
||||
monitorRunning: boolean
|
||||
proxyGroup: string | null
|
||||
proxyNodes: ProxyNodeInfo[]
|
||||
proxyCurrent: string | null
|
||||
}
|
||||
|
||||
// ===== 状态 =====
|
||||
const state = reactive<TrayMenuState>({
|
||||
proxyRunning: false,
|
||||
systemProxy: false,
|
||||
monitorRunning: false,
|
||||
proxyGroup: null,
|
||||
proxyNodes: [],
|
||||
proxyCurrent: null,
|
||||
})
|
||||
const loading = ref(false)
|
||||
const loadingAction = ref<string | null>(null)
|
||||
const refreshing = ref(false)
|
||||
const osdVisible = ref(false)
|
||||
let unlistenFns: UnlistenFn[] = []
|
||||
|
||||
// ===== 节点名缩写(badge 显示) =====
|
||||
// 地区映射
|
||||
function readOsdVisible(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem('thing_monitor_osd_config')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed.config?.overlayEnabled ?? false
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
return false
|
||||
}
|
||||
|
||||
const proxyLoading = computed(() =>
|
||||
loadingAction.value !== null && loadingAction.value.startsWith('proxy')
|
||||
)
|
||||
|
||||
const REGION_MAP: Record<string, string> = {
|
||||
'日本': 'JP', '香港': 'HK', '美国': 'US', '新加坡': 'SG',
|
||||
'台湾': 'TW', '韩国': 'KR', '印度': 'IN', '英国': 'UK',
|
||||
'德国': 'DE', '法国': 'FR', '荷兰': 'NL', '加拿大': 'CA',
|
||||
'澳大利亚': 'AU', '巴西': 'BR', '俄罗斯': 'RU', '泰国': 'TH',
|
||||
'越南': 'VN', '马来西亚': 'MY', '菲律宾': 'PH', '印尼': 'ID',
|
||||
'日本东京': 'JP', '东京': 'JP', '大阪': 'JP',
|
||||
'Japan': 'JP', 'Tokyo': 'JP', 'Osaka': 'JP',
|
||||
'Japan': 'JP',
|
||||
'Hong Kong': 'HK', 'Kong': 'HK',
|
||||
'USA': 'US', 'United States': 'US', 'America': 'US',
|
||||
'Singapore': 'SG', 'Taiwan': 'TW', 'Korea': 'KR', 'India': 'IN',
|
||||
'UK': 'UK', 'London': 'UK',
|
||||
'UK': 'UK',
|
||||
'Germany': 'DE', 'France': 'FR', 'Netherlands': 'NL',
|
||||
'Canada': 'CA', 'Australia': 'AU', 'Brazil': 'BR',
|
||||
'Russia': 'RU', 'Thailand': 'TH', 'Vietnam': 'VN',
|
||||
@@ -60,7 +73,6 @@ const REGION_MAP: Record<string, string> = {
|
||||
'Poland': 'PL', 'Turkey': 'TR', 'Finland': 'FI', 'Norway': 'NO',
|
||||
}
|
||||
|
||||
// 订阅/服务商模式
|
||||
const PROVIDER_PATTERNS: { regex: RegExp; label: string }[] = [
|
||||
{ regex: /\bAWS\b/i, label: 'AWS' },
|
||||
{ regex: /\bGCP\b/i, label: 'GCP' },
|
||||
@@ -92,60 +104,147 @@ const PROVIDER_PATTERNS: { regex: RegExp; label: string }[] = [
|
||||
{ regex: /\bBGP\b/i, label: 'BGP' },
|
||||
]
|
||||
|
||||
function abbreviateNodeName(name: string): string {
|
||||
// 检测服务商
|
||||
let provider = ''
|
||||
/** 城市映射表:节点名包含关键词 → badge 中显示的城市标识。
|
||||
* 城市级别的地名(如圣何塞、东京)从 REGION_MAP 移出至此,
|
||||
* 避免与国家/地区标识重复,并在缩写中加入以区分同名节点。 */
|
||||
const CITY_MAP: Record<string, string> = {
|
||||
// 美国
|
||||
'圣何塞': '圣何塞', '洛杉矶': '洛杉矶', '纽约': '纽约', '硅谷': '硅谷',
|
||||
'西雅图': '西雅图', '芝加哥': '芝加哥', '达拉斯': '达拉斯', '迈阿密': '迈阿密',
|
||||
'旧金山': '旧金山', '凤凰城': '凤凰城', '华盛顿': '华盛顿', '波士顿': '波士顿',
|
||||
'亚特兰大': '亚特兰大', '休斯顿': '休斯顿', '丹佛': '丹佛', '奥斯汀': '奥斯汀',
|
||||
'费城': '费城', '拉斯维加斯': '拉斯维加斯', '波特兰': '波特兰',
|
||||
// 加拿大
|
||||
'多伦多': '多伦多', '温哥华': '温哥华', '蒙特利尔': '蒙特利尔',
|
||||
// 日本
|
||||
'东京': '东京', '大阪': '大阪',
|
||||
// 韩国
|
||||
'首尔': '首尔',
|
||||
// 中国台湾
|
||||
'台北': '台北', '台中': '台中', '高雄': '高雄',
|
||||
// 中国香港
|
||||
'九龙': '九龙',
|
||||
// 英国
|
||||
'伦敦': '伦敦', '曼城': '曼城', '爱丁堡': '爱丁堡',
|
||||
// 德国
|
||||
'法兰克福': '法兰克福', '柏林': '柏林', '慕尼黑': '慕尼黑',
|
||||
// 法国
|
||||
'巴黎': '巴黎',
|
||||
// 荷兰
|
||||
'阿姆斯特丹': '阿姆斯特丹',
|
||||
// 澳大利亚
|
||||
'悉尼': '悉尼', '墨尔本': '墨尔本',
|
||||
// 印度
|
||||
'孟买': '孟买', '德里': '德里', '班加罗尔': '班加罗尔',
|
||||
// 俄罗斯
|
||||
'莫斯科': '莫斯科', '圣彼得堡': '圣彼得堡',
|
||||
// 东南亚
|
||||
'曼谷': '曼谷', '河内': '河内', '胡志明': '胡志明',
|
||||
'吉隆坡': '吉隆坡', '马尼拉': '马尼拉', '雅加达': '雅加达',
|
||||
// 中东
|
||||
'迪拜': '迪拜',
|
||||
// 其他欧洲
|
||||
'伊斯坦布尔': '伊斯坦布尔', '斯德哥尔摩': '斯德哥尔摩',
|
||||
'苏黎世': '苏黎世', '维也纳': '维也纳', '华沙': '华沙',
|
||||
'马德里': '马德里', '罗马': '罗马', '米兰': '米兰',
|
||||
// 英文城市名
|
||||
'San Jose': 'SJC', 'SanJose': 'SJC',
|
||||
'Los Angeles': 'LAX',
|
||||
'New York': 'NYC',
|
||||
'Silicon': '硅谷',
|
||||
'Seattle': 'SEA', 'Chicago': 'CHI', 'Dallas': 'DAL', 'Miami': 'MIA',
|
||||
'San Francisco': 'SFO', 'Phoenix': 'PHX', 'Washington': 'WAS',
|
||||
'Boston': 'BOS', 'Atlanta': 'ATL', 'Houston': 'HOU', 'Denver': 'DEN',
|
||||
'Austin': 'AUS', 'Toronto': 'YTO', 'Vancouver': 'YVR', 'Montreal': 'YUL',
|
||||
'Tokyo': '东京', 'Osaka': '大阪', 'Seoul': '首尔', 'Taipei': '台北',
|
||||
'London': '伦敦', 'Manchester': '曼城',
|
||||
'Frankfurt': '法兰克福', 'Berlin': '柏林', 'Munich': '慕尼黑',
|
||||
'Paris': '巴黎', 'Amsterdam': 'AMS',
|
||||
'Sydney': 'SYD', 'Melbourne': 'MEL',
|
||||
'Mumbai': 'BOM', 'Delhi': 'DEL', 'Moscow': 'MOW',
|
||||
'Bangkok': 'BKK', 'Dubai': 'DXB',
|
||||
}
|
||||
|
||||
interface NodeParts {
|
||||
provider: string
|
||||
region: string
|
||||
city: string
|
||||
num: string
|
||||
ratio: string
|
||||
}
|
||||
|
||||
/** 解析节点名的各组成部分:provider、region、city、编号、倍率 */
|
||||
function parseNodeName(name: string): NodeParts {
|
||||
const parts: NodeParts = { provider: '', region: '', city: '', num: '', ratio: '' }
|
||||
|
||||
for (const p of PROVIDER_PATTERNS) {
|
||||
if (p.regex.test(name)) {
|
||||
provider = p.label
|
||||
parts.provider = p.label
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 检测地区代码
|
||||
let region = ''
|
||||
for (const [key, code] of Object.entries(REGION_MAP)) {
|
||||
if (name.includes(key)) {
|
||||
region = code
|
||||
parts.region = code
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 提取数字部分(节点编号)
|
||||
const numMatch = name.match(/(\d{2,3})/)
|
||||
const num = numMatch ? numMatch[1] : ''
|
||||
for (const [key, label] of Object.entries(CITY_MAP)) {
|
||||
if (name.includes(key)) {
|
||||
parts.city = label
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 组合缩写
|
||||
const parts: string[] = []
|
||||
if (provider) parts.push(provider)
|
||||
if (region) parts.push(region)
|
||||
if (num) {
|
||||
// 有服务商时加点分隔,如 JP-AWS-01
|
||||
parts.push(num)
|
||||
} else if (parts.length === 0) {
|
||||
// 没有匹配到任何模式,兜底
|
||||
// 倍率:先提取,避免与编号数字冲突(如 "01-0.1倍")
|
||||
const ratioMatch = name.match(/(\d+(?:\.\d+)?)\s*倍/)
|
||||
if (ratioMatch) {
|
||||
parts.ratio = ratioMatch[1]
|
||||
}
|
||||
|
||||
// 编号:从移除倍率后的字符串中提取 2-3 位数字
|
||||
const nameWithoutRatio = name.replace(/\d+(?:\.\d+)?\s*倍/g, '')
|
||||
const numMatch = nameWithoutRatio.match(/(\d{2,3})/)
|
||||
if (numMatch) {
|
||||
parts.num = numMatch[1]
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function abbreviateNodeName(name: string): string {
|
||||
const parts = parseNodeName(name)
|
||||
const result: string[] = []
|
||||
|
||||
if (parts.provider) result.push(parts.provider)
|
||||
if (parts.region) result.push(parts.region)
|
||||
if (parts.city) result.push(parts.city)
|
||||
if (parts.num) result.push(parts.num)
|
||||
// 倍率为 1 时不显示(标准倍率无需标注)
|
||||
if (parts.ratio && parts.ratio !== '1') result.push(parts.ratio)
|
||||
|
||||
if (result.length === 0) {
|
||||
const cnMatch = name.match(/^([\u4e00-\u9fa5]{2,4})\s*(\d{2,3})/)
|
||||
if (cnMatch) return `${cnMatch[1]}${cnMatch[2]}`
|
||||
if (/^[A-Za-z]/.test(name)) return name.slice(0, 6)
|
||||
return name.slice(0, 4)
|
||||
}
|
||||
|
||||
return parts.join('-')
|
||||
return result.join('-')
|
||||
}
|
||||
|
||||
// 当前选中节点的缩写
|
||||
const currentNodeAbbr = computed(() => {
|
||||
const node = state.proxyNodes.find(n => n.isCurrent)
|
||||
return node ? abbreviateNodeName(node.name) : ''
|
||||
})
|
||||
|
||||
// 当前选中节点的延迟
|
||||
const currentNodeDelay = computed(() => {
|
||||
const node = state.proxyNodes.find(n => n.isCurrent)
|
||||
return node?.delay ?? null
|
||||
})
|
||||
|
||||
// 按延迟排序的节点列表(有延迟的按升序在前,无延迟的在后)
|
||||
const sortedNodes = computed(() => {
|
||||
const arr = [...state.proxyNodes]
|
||||
arr.sort((a, b) => {
|
||||
@@ -159,23 +258,19 @@ const sortedNodes = computed(() => {
|
||||
return arr
|
||||
})
|
||||
|
||||
// ===== 菜单动作(所有按钮点击后立即关闭菜单,任务在后台执行) =====
|
||||
async function handleAction(action: string, payload?: Record<string, unknown>) {
|
||||
// 立即关闭菜单窗口
|
||||
try { await invoke('tray_menu_hide') } catch { /* 忽略 */ }
|
||||
|
||||
// 后台执行任务(不 await)
|
||||
if (action === 'proxy_refresh') refreshing.value = true
|
||||
loading.value = true
|
||||
loadingAction.value = action
|
||||
invoke('tray_menu_action', { action, payload: payload ?? null })
|
||||
.catch((e) => console.error('[tray-menu] 动作失败:', e))
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
loadingAction.value = null
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// 代理开关按钮文案
|
||||
const proxyButtonLabel = computed(() => state.proxyRunning ? '关闭代理' : '开启代理')
|
||||
const proxyButtonIcon = computed(() => state.proxyRunning ? PowerOff : Power)
|
||||
|
||||
@@ -183,14 +278,16 @@ function handleProxyToggle() {
|
||||
handleAction(state.proxyRunning ? 'proxy_disable' : 'proxy_enable')
|
||||
}
|
||||
|
||||
// 节点选择
|
||||
function handleSystemProxyToggle() {
|
||||
handleAction('system_proxy_toggle')
|
||||
}
|
||||
|
||||
function handleSelectNode(name: unknown) {
|
||||
if (typeof name === 'string') {
|
||||
handleAction('proxy_select_node', { name })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 延迟显示辅助 =====
|
||||
function delayText(delay: number | null): string {
|
||||
if (delay === null) return '未测试'
|
||||
if (delay === 0) return '超时'
|
||||
@@ -204,7 +301,6 @@ function delayClass(delay: number | null): string {
|
||||
return 'delay-slow'
|
||||
}
|
||||
|
||||
// ===== 键盘 =====
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
@@ -212,37 +308,33 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 窗口高度自适应(底部锚定,防止内容变化导致位置偏移) =====
|
||||
const MENU_WIDTH = 260
|
||||
const MENU_MIN_HEIGHT = 200
|
||||
const MENU_MAX_HEIGHT = 520
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
async function adjustWindowHeight() {
|
||||
try {
|
||||
const root = document.querySelector('.tray-menu-root') as HTMLElement | null
|
||||
if (!root) return
|
||||
const contentHeight = root.scrollHeight
|
||||
const height = Math.min(Math.max(contentHeight, MENU_MIN_HEIGHT), MENU_MAX_HEIGHT)
|
||||
const tauriWin = getCurrentWindow()
|
||||
|
||||
// 记录当前窗口底部在屏幕上的物理位置(用于锚定底部,避免高度变化后偏移)
|
||||
const oldPos = await tauriWin.outerPosition()
|
||||
const oldSize = await tauriWin.outerSize()
|
||||
const oldBottom = oldPos.y + oldSize.height
|
||||
|
||||
await tauriWin.setSize(new LogicalSize(MENU_WIDTH, height))
|
||||
|
||||
// 根据新的物理高度,调整 y 坐标使得窗口底部保持在原位
|
||||
const newSize = await tauriWin.outerSize()
|
||||
const newY = Math.max(0, oldBottom - newSize.height)
|
||||
if (Math.abs(newY - oldPos.y) > 2) {
|
||||
await tauriWin.setPosition(new PhysicalPosition(oldPos.x, newY))
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
/** 测量内容高度(逻辑像素) */
|
||||
function measureContentHeight(): number {
|
||||
const root = document.querySelector('.tray-menu-root') as HTMLElement | null
|
||||
if (!root) return MENU_MIN_HEIGHT
|
||||
// 临时取消 max-height 限制以获取真实内容高度
|
||||
const scroll = root.querySelector('.tray-menu-scroll') as HTMLElement | null
|
||||
const prevMax = scroll?.style.maxHeight ?? ''
|
||||
if (scroll) scroll.style.maxHeight = 'none'
|
||||
const height = root.scrollHeight
|
||||
if (scroll) scroll.style.maxHeight = prevMax
|
||||
return Math.min(Math.max(height, MENU_MIN_HEIGHT), MENU_MAX_HEIGHT)
|
||||
}
|
||||
|
||||
/** 测量内容高度并通知 Rust 调整窗口大小、定位、显示 */
|
||||
async function measureAndShow() {
|
||||
await nextTick()
|
||||
const height = measureContentHeight()
|
||||
try {
|
||||
await invoke('tray_menu_ready', { contentHeight: height })
|
||||
} catch (e) {
|
||||
console.error('[tray-menu] tray_menu_ready 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 主题应用(与主应用同步,复用 ClipboardPopup 逻辑) =====
|
||||
function readMainTheme(): { theme: string; effect: string } {
|
||||
try {
|
||||
const raw = localStorage.getItem('thing_app_settings')
|
||||
@@ -308,51 +400,36 @@ async function applyTheme() {
|
||||
onMounted(async () => {
|
||||
await applyTheme()
|
||||
|
||||
// 监听系统主题变化
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onThemeChange = () => applyTheme()
|
||||
mq.addEventListener('change', onThemeChange)
|
||||
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
|
||||
|
||||
// 监听菜单显示事件:每次显示时重新同步主题 + 刷新状态 + 调整高度
|
||||
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
|
||||
await applyTheme()
|
||||
Object.assign(state, event.payload)
|
||||
await nextTick()
|
||||
await adjustWindowHeight()
|
||||
osdVisible.value = readOsdVisible()
|
||||
await measureAndShow()
|
||||
}))
|
||||
|
||||
// 监听状态更新事件(动作执行后刷新 + 调整高度)
|
||||
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
|
||||
// 仅更新状态数据,不重新显示窗口。
|
||||
// 动作完成后的状态更新不应让已隐藏的菜单重新弹出(measureAndShow 会触发 win.show)。
|
||||
// 菜单显示统一由右键托盘触发的 tray-menu-show 事件负责。
|
||||
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', (event) => {
|
||||
Object.assign(state, event.payload)
|
||||
await nextTick()
|
||||
await adjustWindowHeight()
|
||||
}))
|
||||
|
||||
// 使用 ResizeObserver 监听内容尺寸变化,自动调整窗口高度
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
adjustWindowHeight()
|
||||
})
|
||||
const root = document.querySelector('.tray-menu-root') as HTMLElement | null
|
||||
if (root) resizeObserver.observe(root)
|
||||
|
||||
// 预创建模式下:通知 Rust 端前端已就绪,触发初始状态推送(窗口保持隐藏)
|
||||
try {
|
||||
await invoke('tray_menu_show_window')
|
||||
} catch { /* 忽略 */ }
|
||||
// 预创建模式下不再调用 tray_menu_show_window,窗口显示统一由 tray_menu_ready 触发
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenFns.forEach((fn) => fn())
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tray-menu-root flex flex-col" @keydown="onKeydown" tabindex="0">
|
||||
<!-- 菜单内容 -->
|
||||
<div class="tray-menu-scroll flex-1 min-h-0 overflow-y-auto">
|
||||
<!-- ===== 代理 section ===== -->
|
||||
<div class="tray-section">
|
||||
<div class="tray-section-header">
|
||||
<Globe class="h-4 w-4 text-primary" />
|
||||
@@ -362,31 +439,33 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="tray-section-body">
|
||||
<!-- 开启/关闭代理(单按钮切换) -->
|
||||
<button class="tray-item" :disabled="loading" @click="handleProxyToggle">
|
||||
<button class="tray-item" :disabled="proxyLoading" @click="handleProxyToggle">
|
||||
<component :is="proxyButtonIcon" class="h-4 w-4" />
|
||||
<span>{{ proxyButtonLabel }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 刷新节点列表 -->
|
||||
<button v-if="state.proxyRunning" class="tray-item" :disabled="proxyLoading" @click="handleSystemProxyToggle">
|
||||
<Power class="h-4 w-4" />
|
||||
<span>系统代理</span>
|
||||
<span class="tray-toggle-indicator" :class="{ active: state.systemProxy }"></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="state.proxyRunning"
|
||||
class="tray-item"
|
||||
:disabled="loading"
|
||||
:disabled="proxyLoading"
|
||||
@click="handleAction('proxy_refresh')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': refreshing }" />
|
||||
<span>刷新节点列表</span>
|
||||
</button>
|
||||
|
||||
<!-- 节点选择 Select -->
|
||||
<div v-if="state.proxyRunning && state.proxyNodes.length > 0" class="tray-node-select">
|
||||
<label class="tray-node-label">节点</label>
|
||||
<Select
|
||||
:model-value="state.proxyCurrent ?? ''"
|
||||
:disabled="loading"
|
||||
:disabled="proxyLoading"
|
||||
@update:model-value="handleSelectNode"
|
||||
@update:open="(open: boolean) => { if (open) invoke('tray_menu_action', { action: 'proxy_refresh', payload: null }) }"
|
||||
>
|
||||
<SelectTrigger class="tray-select-trigger" size="sm">
|
||||
<span v-if="currentNodeAbbr" class="tray-select-badge">{{ currentNodeAbbr }}</span>
|
||||
@@ -416,36 +495,49 @@ onUnmounted(() => {
|
||||
|
||||
<div class="tray-separator"></div>
|
||||
|
||||
<!-- ===== OSD + Kernel ===== -->
|
||||
<button class="tray-item" :disabled="loading" @click="handleAction('osd_toggle')">
|
||||
<Monitor class="h-4 w-4" />
|
||||
<span>切换 OSD</span>
|
||||
</button>
|
||||
<button
|
||||
class="tray-item"
|
||||
:disabled="!state.monitorRunning || loading"
|
||||
@click="handleAction('kernel_restart')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
<span>重启 Kernel</span>
|
||||
</button>
|
||||
<div class="tray-section">
|
||||
<div class="tray-section-header">
|
||||
<Monitor class="h-4 w-4 text-primary" />
|
||||
<span class="tray-section-title">监控</span>
|
||||
<span v-if="state.monitorRunning" class="tray-status-dot running"></span>
|
||||
<span v-else class="tray-status-dot stopped"></span>
|
||||
</div>
|
||||
|
||||
<div class="tray-section-body">
|
||||
<button class="tray-item" @click="handleAction('osd_toggle')">
|
||||
<Monitor class="h-4 w-4" />
|
||||
<span>OSD 显示</span>
|
||||
<span class="tray-toggle-indicator" :class="{ active: osdVisible }"></span>
|
||||
</button>
|
||||
<button
|
||||
class="tray-item"
|
||||
:disabled="!state.monitorRunning"
|
||||
@click="handleAction('kernel_restart')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
<span>重启 Kernel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tray-separator"></div>
|
||||
|
||||
<!-- ===== 新建下载 ===== -->
|
||||
<button class="tray-item" :disabled="loading" @click="handleAction('download_new')">
|
||||
<button class="tray-item" @click="handleAction('download_new')">
|
||||
<Download class="h-4 w-4" />
|
||||
<span>新建下载</span>
|
||||
</button>
|
||||
<button class="tray-item" @click="handleAction('screenshot_region')">
|
||||
<Camera class="h-4 w-4" />
|
||||
<span>区域截图</span>
|
||||
</button>
|
||||
|
||||
<div class="tray-separator"></div>
|
||||
|
||||
<!-- ===== 设置 + 退出 ===== -->
|
||||
<button class="tray-item" :disabled="loading" @click="handleAction('settings')">
|
||||
<button class="tray-item" @click="handleAction('settings')">
|
||||
<Settings class="h-4 w-4" />
|
||||
<span>常规设置</span>
|
||||
</button>
|
||||
<button class="tray-item tray-quit" :disabled="loading" @click="handleAction('quit')">
|
||||
<button class="tray-item tray-quit" @click="handleAction('quit')">
|
||||
<LogOut class="h-4 w-4" />
|
||||
<span>退出</span>
|
||||
</button>
|
||||
@@ -464,14 +556,12 @@ onUnmounted(() => {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 滚动区域(高度由内容决定,不用 flex-1 撑满) */
|
||||
.tray-menu-scroll {
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.tray-menu-scroll::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
@@ -484,7 +574,6 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== Section ===== */
|
||||
.tray-section {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@@ -513,7 +602,6 @@ onUnmounted(() => {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* 状态指示点 */
|
||||
.tray-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -529,7 +617,6 @@ onUnmounted(() => {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ===== 菜单项 ===== */
|
||||
.tray-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -565,7 +652,36 @@ onUnmounted(() => {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 退出项特殊样式 */
|
||||
.tray-toggle-indicator {
|
||||
margin-left: auto;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--muted-foreground);
|
||||
opacity: 0.3;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tray-toggle-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.tray-toggle-indicator.active {
|
||||
background: var(--primary);
|
||||
opacity: 1;
|
||||
}
|
||||
.tray-toggle-indicator.active::after {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
.tray-quit {
|
||||
color: var(--destructive);
|
||||
}
|
||||
@@ -576,14 +692,12 @@ onUnmounted(() => {
|
||||
background: color-mix(in oklch, var(--destructive) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ===== 分隔线 ===== */
|
||||
.tray-separator {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 10px;
|
||||
}
|
||||
|
||||
/* ===== 节点选择 ===== */
|
||||
.tray-node-select {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
@@ -598,7 +712,6 @@ onUnmounted(() => {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Select trigger 样式覆盖 */
|
||||
.tray-select-trigger {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
@@ -611,7 +724,6 @@ onUnmounted(() => {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
/* 选中节点的 badge 缩写 */
|
||||
.tray-select-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -626,7 +738,6 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* SelectTrigger 内的延迟 badge */
|
||||
.tray-select-delay-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -647,7 +758,6 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 下拉选项中每个节点的 badge */
|
||||
.tray-select-badge-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -671,7 +781,6 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 延迟配色 */
|
||||
.delay-fast {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: rgb(22, 163, 74);
|
||||
@@ -693,7 +802,6 @@ onUnmounted(() => {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* 深色主题下调整延迟配色 */
|
||||
.dark .delay-fast {
|
||||
color: rgb(74, 222, 128);
|
||||
}
|
||||
@@ -708,10 +816,8 @@ onUnmounted(() => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 全局样式:SelectContent 通过 Portal 渲染到 body 外部,scoped 样式不生效 -->
|
||||
<style>
|
||||
.tray-select-content[data-slot="select-content"] {
|
||||
/* 固定宽度等于 trigger 宽度,防止长节点名撑宽下拉框 */
|
||||
width: var(--reka-select-trigger-width);
|
||||
min-width: var(--reka-select-trigger-width);
|
||||
max-width: var(--reka-select-trigger-width);
|
||||
|
||||
Reference in New Issue
Block a user