526 lines
21 KiB
Vue
526 lines
21 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onUnmounted, watch, nextTick } from 'vue'
|
||
import {
|
||
Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer, StickyNote,
|
||
Image as ImageIcon, Loader2,
|
||
} from '@lucide/vue'
|
||
import { toast } from 'vue-sonner'
|
||
import { open } from '@tauri-apps/plugin-dialog'
|
||
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
|
||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||
|
||
const store = useScreenshotStore()
|
||
|
||
const activeTab = ref('settings')
|
||
const tabsListRef = useModuleTabs('screenshot', activeTab, [
|
||
{ value: 'settings', label: '设置' },
|
||
{ value: 'history', label: '历史' },
|
||
])
|
||
|
||
const HISTORY_LIMITS = [24, 48, 96]
|
||
const DELAY_OPTIONS = [0, 1, 2, 3, 5]
|
||
|
||
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 item.thumb
|
||
}
|
||
|
||
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) {
|
||
try {
|
||
// 完整图从缓存按需加载(历史内存只保留缩略图)
|
||
const full = await store.loadFullImage(item)
|
||
await store.copyImage(full)
|
||
} catch (e) {
|
||
toast.error('加载完整图失败: ' + e)
|
||
}
|
||
}
|
||
|
||
async function handleSave(item: RecentCapture) {
|
||
try {
|
||
const full = await store.loadFullImage(item)
|
||
await store.saveImage(full)
|
||
} catch (e) {
|
||
toast.error('加载完整图失败: ' + e)
|
||
}
|
||
}
|
||
|
||
async function handlePin(item: RecentCapture) {
|
||
const idx = store.recent.findIndex((r) => r.id === item.id)
|
||
await store.togglePin(idx >= 0 ? idx : 0)
|
||
}
|
||
|
||
function handleDelete(item: RecentCapture) {
|
||
void store.removeRecent(item)
|
||
toast.success('已从历史移除')
|
||
}
|
||
|
||
// ===== 历史保留数量:预设 + 自定义输入 =====
|
||
const customLimit = ref('')
|
||
|
||
function onLimitPreset(n: number) {
|
||
store.setSettings({ historyLimit: n })
|
||
}
|
||
|
||
function onCustomLimit() {
|
||
const v = parseInt(customLimit.value, 10)
|
||
if (!Number.isFinite(v) || v < 1) {
|
||
toast.warning('请输入大于 0 的数字')
|
||
return
|
||
}
|
||
store.setSettings({ historyLimit: Math.min(999, Math.max(1, Math.floor(v))) })
|
||
}
|
||
|
||
// 当前值不在预设中时,把自定义输入框预填为当前值
|
||
watch(
|
||
() => store.settings.historyLimit,
|
||
(v) => {
|
||
if (!HISTORY_LIMITS.includes(v)) customLimit.value = String(v)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// ===== 快捷键录入器(截图 / 贴图共用) =====
|
||
const recordingField = ref<'capture' | 'pin' | null>(null)
|
||
const captureRecorderRef = ref<HTMLDivElement | null>(null)
|
||
const pinRecorderRef = 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 (!recordingField.value) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (e.key === 'Escape') {
|
||
recordingField.value = null
|
||
return
|
||
}
|
||
// 仅修饰键按下时不结束(等待主键)
|
||
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
|
||
const combo = eventToShortcut(e)
|
||
if (!combo) {
|
||
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
|
||
return
|
||
}
|
||
const field = recordingField.value
|
||
recordingField.value = null
|
||
void commitShortcut(field, combo)
|
||
}
|
||
|
||
async function commitShortcut(field: 'capture' | 'pin', combo: string) {
|
||
const ok = field === 'capture'
|
||
? await store.setShortcut(combo)
|
||
: await store.setPinShortcut(combo)
|
||
if (ok) toast.success(`快捷键已更新为 ${displayShortcut(combo)}`)
|
||
else toast.error('快捷键注册失败,可能被其他程序占用')
|
||
}
|
||
|
||
async function startRecord(field: 'capture' | 'pin') {
|
||
recordingField.value = field
|
||
await nextTick()
|
||
;(field === 'capture' ? captureRecorderRef : pinRecorderRef).value?.focus()
|
||
}
|
||
|
||
watch(recordingField, (field) => {
|
||
if (field) window.addEventListener('keydown', onRecordKey, true)
|
||
else window.removeEventListener('keydown', onRecordKey, true)
|
||
})
|
||
|
||
async function clearShortcut() {
|
||
await store.setShortcut('')
|
||
toast.success('已禁用截图快捷键')
|
||
}
|
||
|
||
async function clearPinShortcut() {
|
||
await store.setPinShortcut('')
|
||
toast.success('已禁用贴图快捷键')
|
||
}
|
||
|
||
// 导出监听(screenshot-exported)由应用级注册(main.ts/App.vue),随应用生命周期管理;
|
||
// 模块卸载不得销毁该单例监听,否则离开截图模块后全局快捷键截图将不记录历史/不自动保存。
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', onRecordKey, true)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-full p-6 overflow-hidden flex flex-col">
|
||
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
|
||
<div ref="tabsListRef" class="shrink-0">
|
||
<TabsList class="grid w-full max-w-md grid-cols-2 !bg-transparent !p-0 !shadow-none">
|
||
<TabsTrigger value="settings" class="gap-1.5"><Settings class="size-3.5" />设置</TabsTrigger>
|
||
<TabsTrigger value="history" class="gap-1.5">
|
||
<ImageIcon class="size-3.5" />历史
|
||
<span v-if="store.recent.length" class="text-xs text-muted-foreground">({{ store.recent.length }})</span>
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<!-- 截图设置 -->
|
||
<TabsContent value="settings" class="flex-1 min-h-0 mt-4">
|
||
<ScrollArea class="h-full">
|
||
<div class="space-y-4 max-w-3xl">
|
||
<!-- 快捷键 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="text-sm flex items-center gap-2">
|
||
<Keyboard class="size-4 text-primary" />
|
||
截图快捷键
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<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="captureRecorderRef"
|
||
class="hotkey-recorder"
|
||
:class="{ recording: recordingField === 'capture' }"
|
||
tabindex="0"
|
||
@click="startRecord('capture')"
|
||
>
|
||
<template v-if="recordingField === 'capture'">按下快捷键…(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 + 1
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="secondary"
|
||
:disabled="store.capturing"
|
||
@click="handleCapture"
|
||
>
|
||
<Loader2 v-if="store.capturing" class="size-4 animate-spin" />
|
||
<Camera v-else class="size-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>· 点击标注可选中移动,文字标注点击内容可重新编辑</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">
|
||
<StickyNote class="size-4 text-primary" />
|
||
贴图快捷键
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<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="pinRecorderRef"
|
||
class="hotkey-recorder"
|
||
:class="{ recording: recordingField === 'pin' }"
|
||
tabindex="0"
|
||
@click="startRecord('pin')"
|
||
>
|
||
<template v-if="recordingField === 'pin'">按下快捷键…(Esc 取消)</template>
|
||
<template v-else-if="store.settings.pinShortcut">
|
||
{{ displayShortcut(store.settings.pinShortcut) }}
|
||
</template>
|
||
<template v-else>未设置(点击录入)</template>
|
||
</div>
|
||
<Button
|
||
v-if="store.settings.pinShortcut"
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-8 text-muted-foreground"
|
||
@click="clearPinShortcut"
|
||
>清除</Button>
|
||
</div>
|
||
<p class="text-sm text-muted-foreground">
|
||
把最近截图以原分辨率贴到屏幕(显示在原框选位置),再次按下可关闭贴图。默认 Alt + 1
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<!-- 截图选项 -->
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle class="text-sm flex items-center gap-2">
|
||
<Settings class="size-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="size-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="size-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 items-center gap-1.5 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="onLimitPreset(n)"
|
||
>{{ n }}</Button>
|
||
<input
|
||
v-model="customLimit"
|
||
type="number"
|
||
min="1"
|
||
max="999"
|
||
class="h-8 w-16 rounded-md border border-input bg-transparent px-2 text-sm text-center outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||
placeholder="自定义"
|
||
title="输入自定义保留数量"
|
||
@keydown.enter="onCustomLimit"
|
||
@blur="onCustomLimit"
|
||
>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<!-- 历史记录 -->
|
||
<TabsContent value="history" class="flex-1 min-h-0 mt-0">
|
||
<ScrollArea class="h-full">
|
||
<div>
|
||
<!-- 空状态 -->
|
||
<div
|
||
v-if="store.recent.length === 0"
|
||
class="flex flex-col items-center justify-center h-64 text-muted-foreground gap-3"
|
||
>
|
||
<ImageIcon class="size-12 opacity-40" />
|
||
<p>暂无截图历史</p>
|
||
<p class="text-xs">按 Ctrl + 1 截图并导出后会显示在这里</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"
|
||
>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon-sm"
|
||
@click.stop="handlePin(item)"
|
||
>
|
||
<StickyNote class="size-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>贴到屏幕</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon-sm"
|
||
@click.stop="handleCopy(item)"
|
||
>
|
||
<Copy class="size-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制到剪贴板</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon-sm"
|
||
@click.stop="handleSave(item)"
|
||
>
|
||
<Save class="size-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>保存到文件</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button
|
||
variant="secondary"
|
||
size="icon-sm"
|
||
class="text-destructive hover:text-destructive"
|
||
@click.stop="handleDelete(item)"
|
||
>
|
||
<Trash2 class="size-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>从历史移除</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
</template>
|