截图模块调整

This commit is contained in:
2026-08-01 22:24:33 +08:00
parent 89e5b7bed5
commit f51a7f894e
13 changed files with 2408 additions and 527 deletions
+249 -52
View File
@@ -1,29 +1,30 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Camera, Square, AppWindow, Maximize, Copy, Save, Trash2, Image as ImageIcon, Loader2,
Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer,
Image as ImageIcon, Loader2,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useScreenshotStore, type CaptureMode, type RecentCapture } from '@/stores/screenshotStore'
import { open } from '@tauri-apps/plugin-dialog'
import { useScreenshotStore, 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'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
const store = useScreenshotStore()
const activeTab = ref('capture')
const activeTab = ref('settings')
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'capture', label: '截图' },
{ value: 'settings', 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: '直接捕获整个虚拟屏' },
]
const HISTORY_LIMITS = [6, 12, 24, 48]
const DELAY_OPTIONS = [0, 1, 2, 3, 5]
function formatTime(t: number): string {
const d = new Date(t)
@@ -37,8 +38,16 @@ function thumbSrc(item: RecentCapture): string {
return `data:image/png;base64,${item.pngBase64}`
}
async function handleCapture(mode: CaptureMode) {
await store.startCapture(mode)
async function handleCapture() {
await store.startCapture()
}
async function chooseSaveDir() {
const dir = await open({ directory: true, title: '选择自动保存目录' })
if (typeof dir === 'string') {
store.setSettings({ saveDir: dir })
toast.success('自动保存目录已更新')
}
}
async function handleCopy(item: RecentCapture) {
@@ -55,12 +64,98 @@ function handleDelete(item: RecentCapture) {
toast.success('已从历史移除')
}
// ===== 快捷键录入器 =====
const recording = ref(false)
const recorderRef = ref<HTMLDivElement | null>(null)
/** 把存储的快捷键字符串格式化为展示形式:ctrl+alt+a → Ctrl + Alt + A */
function displayShortcut(s: string): string {
if (!s) return ''
return s
.split('+')
.map(p => {
const t = p.trim()
if (!t) return ''
if (t.length === 1) return t.toUpperCase()
return t.charAt(0).toUpperCase() + t.slice(1)
})
.join(' + ')
}
/** 把键盘事件转为 Tauri 快捷键字符串(小写,+ 分隔) */
function eventToShortcut(e: KeyboardEvent): string | null {
const mods: string[] = []
if (e.ctrlKey) mods.push('ctrl')
if (e.altKey) mods.push('alt')
if (e.shiftKey) mods.push('shift')
if (e.metaKey) mods.push('super')
// 主键
let main = ''
const code = e.code || ''
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
else if (code === 'Space') main = 'space'
else if (code === 'PrintScreen') main = 'printscreen'
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
else {
// 退格/回车等单键不允许(避免误触);回车/Escape 由录制器单独处理
return null
}
// 必须至少一个修饰键(功能键 F1-F12 / PrintScreen 例外)
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
if (mods.length === 0 && !isFunctionKey) return null
return [...mods, main].join('+')
}
function onRecordKey(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording.value = false
return
}
// 仅修饰键按下时不结束(等待主键)
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
const combo = eventToShortcut(e)
if (!combo) {
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
return
}
recording.value = false
void commitShortcut(combo)
}
async function commitShortcut(combo: string) {
const ok = await store.setShortcut(combo)
if (ok) toast.success(`快捷键已更新为 ${displayShortcut(combo)}`)
else toast.error('快捷键注册失败,可能被其他程序占用')
}
async function startRecord() {
recording.value = true
await nextTick()
recorderRef.value?.focus()
}
watch(recording, (on) => {
if (on) window.addEventListener('keydown', onRecordKey, true)
else window.removeEventListener('keydown', onRecordKey, true)
})
async function clearShortcut() {
await store.setShortcut('')
toast.success('已禁用截图快捷键')
}
onMounted(() => {
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
})
onUnmounted(() => {
store.destroyExportListener()
window.removeEventListener('keydown', onRecordKey, true)
})
</script>
@@ -69,7 +164,7 @@ onUnmounted(() => {
<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="settings">设置</TabsTrigger>
<TabsTrigger value="history">
历史
<span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
@@ -79,50 +174,152 @@ onUnmounted(() => {
</TabsList>
</div>
<!-- 截图触发 -->
<TabsContent value="capture" class="flex-1 min-h-0 mt-0">
<!-- 截图设置 -->
<TabsContent value="settings" 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>
<!-- 说明 -->
<div class="p-4 pt-0 space-y-4 max-w-3xl mx-auto">
<!-- 快捷键 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Camera class="h-4 w-4 text-primary" />
使用说明
<Keyboard 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>
<div class="flex items-center justify-between gap-4 py-1">
<div class="space-y-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<!-- 快捷键录入器 -->
<div
ref="recorderRef"
class="hotkey-recorder"
:class="{ recording }"
tabindex="0"
@click="startRecord"
>
<template v-if="recording">按下快捷键Esc 取消</template>
<template v-else-if="store.settings.shortcut">
{{ displayShortcut(store.settings.shortcut) }}
</template>
<template v-else>未设置点击录入</template>
</div>
<Button
v-if="store.settings.shortcut"
variant="ghost"
size="sm"
class="h-8 text-muted-foreground"
@click="clearShortcut"
>清除</Button>
</div>
<p class="text-sm text-muted-foreground">
点击方框录入自定义快捷键留空则禁用默认 Ctrl + Alt + A
</p>
</div>
<Button
variant="secondary"
:disabled="store.capturing"
@click="handleCapture"
>
<Loader2 v-if="store.capturing" class="h-4 w-4 animate-spin" />
<Camera v-else class="h-4 w-4" />
立即截图
</Button>
</div>
<div class="mt-3 pt-3 border-t text-sm text-muted-foreground space-y-1.5">
<p>· 进入截图后 <span class="text-foreground">移动鼠标</span> 自动识别窗口<span class="text-foreground">点击</span> 选中窗口</p>
<p>· <span class="text-foreground">长按拖动</span> 自由选择区域选区可拖动 / 缩放手柄调整大小</p>
<p>· 选区右下方编辑栏可标注矩形 / 椭圆 / 箭头 / 序号 / 画笔 / 文字 / 马赛克 / 高亮</p>
<p>
· <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Enter</kbd> / 双击复制并完成
<kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 逐级取消
</p>
</div>
</CardContent>
</Card>
<!-- 截图选项 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Settings class="h-4 w-4 text-primary" />
截图选项
</CardTitle>
</CardHeader>
<CardContent>
<!-- 延时截图 -->
<div class="flex items-center justify-between gap-3 py-2">
<div class="space-y-1">
<Label class="flex items-center gap-2 text-base font-medium">
<Timer class="h-4 w-4" />
延时截图
</Label>
<p class="text-sm text-muted-foreground">按下快捷键后倒计时再截图便于切到目标画面</p>
</div>
<div class="flex gap-1 shrink-0">
<Button
v-for="n in DELAY_OPTIONS"
:key="n"
variant="ghost"
size="sm"
class="h-8 px-2.5"
:class="store.settings.delay === n
? 'bg-primary/10 text-primary hover:bg-primary/10'
: 'text-muted-foreground'"
@click="store.setSettings({ delay: n })"
>{{ n === 0 ? '立即' : n + 's' }}</Button>
</div>
</div>
<!-- 自动保存 -->
<div class="flex items-center justify-between py-2 border-t">
<div class="space-y-1">
<Label class="text-base font-medium">自动保存到目录</Label>
<p class="text-sm text-muted-foreground">
截图完成后自动保存 PNG 到指定目录
</p>
</div>
<Switch
:model-value="store.settings.autoSave"
@update:model-value="(v: boolean) => store.setSettings({ autoSave: v })"
/>
</div>
<!-- 保存目录 -->
<div v-if="store.settings.autoSave" class="flex items-center justify-between gap-3 py-2 border-t">
<div class="space-y-1 min-w-0">
<Label class="text-base font-medium">保存目录</Label>
<p class="text-sm text-muted-foreground truncate">
{{ store.settings.saveDir || '未设置目录,自动保存不会生效' }}
</p>
</div>
<Button variant="outline" size="sm" class="shrink-0" @click="chooseSaveDir">
<FolderOpen class="h-4 w-4" />
选择目录
</Button>
</div>
<!-- 历史保留数量 -->
<div class="flex items-center justify-between gap-3 py-2 border-t">
<div class="space-y-1">
<Label class="text-base font-medium">历史保留数量</Label>
<p class="text-sm text-muted-foreground">超过上限的旧截图将自动移除</p>
</div>
<div class="flex gap-1 shrink-0">
<Button
v-for="n in HISTORY_LIMITS"
:key="n"
variant="ghost"
size="sm"
class="h-8 px-2.5"
:class="store.settings.historyLimit === n
? 'bg-primary/10 text-primary hover:bg-primary/10'
: 'text-muted-foreground'"
@click="store.setSettings({ historyLimit: n })"
>{{ n }}</Button>
</div>
</div>
</CardContent>
</Card>
</div>
@@ -140,7 +337,7 @@ onUnmounted(() => {
>
<ImageIcon class="h-12 w-12 opacity-40" />
<p>暂无截图历史</p>
<p class="text-xs">截图并导出后会显示在这里</p>
<p class="text-xs">按 Ctrl + Alt + A 截图并导出后会显示在这里</p>
</div>
<!-- 历史网格 -->
File diff suppressed because it is too large Load Diff