Files
Thing/src/modules/screenshot/ScreenshotModule.vue
T
2026-08-11 17:19:36 +08:00

526 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>