Compare commits

...

3 Commits

10 changed files with 560 additions and 128 deletions

View File

@@ -9,12 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"mousetrap": "^1.6.5",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/mousetrap": "^1.6.15",
"@types/node": "^22.10.1", "@types/node": "^22.10.1",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"sass-embedded": "^1.82.0", "sass-embedded": "^1.82.0",

16
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.: .:
dependencies: dependencies:
mousetrap:
specifier: ^1.6.5
version: 1.6.5
vue: vue:
specifier: ^3.5.13 specifier: ^3.5.13
version: 3.5.13(typescript@5.7.2) version: 3.5.13(typescript@5.7.2)
@@ -18,9 +15,6 @@ importers:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.5.0(vue@3.5.13(typescript@5.7.2)) version: 4.5.0(vue@3.5.13(typescript@5.7.2))
devDependencies: devDependencies:
'@types/mousetrap':
specifier: ^1.6.15
version: 1.6.15
'@types/node': '@types/node':
specifier: ^22.10.1 specifier: ^22.10.1
version: 22.10.1 version: 22.10.1
@@ -304,9 +298,6 @@ packages:
'@types/estree@1.0.6': '@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
'@types/mousetrap@1.6.15':
resolution: {integrity: sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==}
'@types/node@22.10.1': '@types/node@22.10.1':
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
@@ -385,9 +376,6 @@ packages:
magic-string@0.30.15: magic-string@0.30.15:
resolution: {integrity: sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==} resolution: {integrity: sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==}
mousetrap@1.6.5:
resolution: {integrity: sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==}
nanoid@3.3.8: nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -766,8 +754,6 @@ snapshots:
'@types/estree@1.0.6': {} '@types/estree@1.0.6': {}
'@types/mousetrap@1.6.15': {}
'@types/node@22.10.1': '@types/node@22.10.1':
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
@@ -881,8 +867,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
mousetrap@1.6.5: {}
nanoid@3.3.8: {} nanoid@3.3.8: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}

View File

@@ -2,7 +2,7 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
router.push('/project') router.push('/config')
</script> </script>
<template> <template>

View File

@@ -2,8 +2,7 @@
import VirtualList from './VirtualList.vue' import VirtualList from './VirtualList.vue'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue' import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import type { ListItem, MenuItem } from '@/types' import type { ListItem, MenuItem } from '@/types'
import Mousetrap from 'mousetrap' import { keyboardManager } from '@/utils/KeyboardManager'
import 'mousetrap/plugins/global-bind/mousetrap-global-bind'
/** /**
* 组件属性接口 * 组件属性接口
@@ -54,7 +53,7 @@ const toastMessage = ref<string>('')
/** 是否正在使用键盘导航菜单 */ /** 是否正在使用键盘导航菜单 */
const isKeyboardNavigation = ref<boolean>(false) const isKeyboardNavigation = ref<boolean>(false)
/** 快捷键处理函数映射 */ /** 快捷键处理函数映射 */
const shortcutHandlers = new Map<string, () => void>() const shortcutHandlers = new Map<string, (e: KeyboardEvent) => void>()
// ===== 定时器 ===== // ===== 定时器 =====
/** 鼠标悬停禁用定时器 */ /** 鼠标悬停禁用定时器 */
@@ -105,12 +104,10 @@ const closeMenu = (): void => {
isKeyboardNavigation.value = false isKeyboardNavigation.value = false
// 清除快捷键绑定 // 清除快捷键绑定
shortcutHandlers.forEach((_, shortcut) => { keyboardManager.disable()
Mousetrap.unbind(shortcut)
})
shortcutHandlers.clear() shortcutHandlers.clear()
// 临时禁用鼠标悬停,防止菜单关闭时即触发悬停效果 // 临时禁用鼠标悬停,防止菜单关闭时<EFBFBD><EFBFBD><EFBFBD>即触发悬停效果
temporaryDisableHover.value = true temporaryDisableHover.value = true
if (hoverDisableTimer) clearTimeout(hoverDisableTimer) if (hoverDisableTimer) clearTimeout(hoverDisableTimer)
hoverDisableTimer = window.setTimeout(() => { hoverDisableTimer = window.setTimeout(() => {
@@ -158,7 +155,7 @@ const handleMenuTriggerClick = (e: MouseEvent): void => {
* - 左方向键/ESC关闭菜单 * - 左方向键/ESC关闭菜单
* - 上下方向键:在菜单打开时导航菜单项 * - 上下方向键:在菜单打开时导航菜单项
* - 回车键:在菜单打开时选择当前菜单项 * - 回车键:在菜单打开时选择当前菜单项
* @param e 键盘事件 * @param e 键盘事件<EFBFBD><EFBFBD>
*/ */
const handleKeyDown = (e: KeyboardEvent): void => { const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'ArrowRight' && !showMenu.value && selectedItem.value) { if (e.key === 'ArrowRight' && !showMenu.value && selectedItem.value) {
@@ -181,7 +178,7 @@ const handleKeyDown = (e: KeyboardEvent): void => {
case 'ArrowDown': case 'ArrowDown':
e.preventDefault() e.preventDefault()
isKeyboardNavigation.value = true isKeyboardNavigation.value = true
// 向下选<EFBFBD><EFBFBD>菜单项,到底部时循环到顶部 // 向下选菜单项,到底部时循环到顶部
selectedMenuIndex.value = selectedMenuIndex.value =
selectedMenuIndex.value >= currentMenuItems.value.length - 1 selectedMenuIndex.value >= currentMenuItems.value.length - 1
? 0 ? 0
@@ -232,10 +229,7 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
if (toastTimer) clearTimeout(toastTimer) if (toastTimer) clearTimeout(toastTimer)
if (hoverDisableTimer) clearTimeout(hoverDisableTimer) if (hoverDisableTimer) clearTimeout(hoverDisableTimer)
// 清除所有快捷键绑定 keyboardManager.disable()
shortcutHandlers.forEach((_, shortcut) => {
Mousetrap.unbind(shortcut)
})
}) })
// ===== 过渡钩子 ===== // ===== 过渡钩子 =====
@@ -257,46 +251,31 @@ const onAfterLeave = (el: Element): void => {
(el as HTMLElement).style.display = '' (el as HTMLElement).style.display = ''
} }
// 在导入后添加 Mousetrap 配置
Mousetrap.prototype.stopCallback = (e: KeyboardEvent, element: HTMLElement, combo: string) => {
// 当菜单打开时,总是响应快捷键
if (showMenu.value) {
e.preventDefault()
e.stopPropagation()
return false
}
// 否则使用默认行为
return element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA'
}
// 修改 bindShortcuts 函数 // 修改 bindShortcuts 函数
const bindShortcuts = (): void => { const bindShortcuts = (): void => {
// 清除之前的绑定 // 清除之前的绑定
shortcutHandlers.forEach((_, shortcut) => { shortcutHandlers.forEach((_, shortcut) => {
Mousetrap.unbind(shortcut) keyboardManager.unbind(shortcut)
}) })
shortcutHandlers.clear() shortcutHandlers.clear()
// 只在菜单打开时绑定快捷键 // 只在菜单打开时绑定快捷键
if (showMenu.value) { if (showMenu.value) {
keyboardManager.enable()
currentMenuItems.value.forEach((item) => { currentMenuItems.value.forEach((item) => {
if (item.shortcut) { if (item.shortcut) {
const handler = (e: ExtendedKeyboardEvent) => { const handler = (e: KeyboardEvent) => {
// 阻止浏览器默认行为
e.preventDefault()
e.stopPropagation()
if (selectedItem.value || !item.action.toString().includes('selectedItem')) { if (selectedItem.value || !item.action.toString().includes('selectedItem')) {
item.action(selectedItem.value) item.action(selectedItem.value)
// 执行后关闭菜单
closeMenu() closeMenu()
} }
return false // 阻止事件冒泡
} }
shortcutHandlers.set(item.shortcut, handler) shortcutHandlers.set(item.shortcut, handler)
Mousetrap.bindGlobal(item.shortcut, handler) keyboardManager.bind(item.shortcut, handler)
} }
}) })
} else {
keyboardManager.disable()
} }
} }
</script> </script>
@@ -438,7 +417,7 @@ const bindShortcuts = (): void => {
min-height: 0; min-height: 0;
} }
/* 工具栏固定高度,顶部边框和阴影效果 */ /* 工具栏固定高度,顶部边框和阴影效果 */
.toolbar { .toolbar {
height: 40px; height: 40px;
background-color: #fff; background-color: #fff;

View File

@@ -1,66 +1,166 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { ConfigGroup, TextConfigItem, SelectConfigItem, SwitchConfigItem, ConfigItemType } from '@/types/config'
import { ConfigManager, LocalStorageConfigStore } from '@/utils/ConfigStore'
// 配置项状态 const configManager = ConfigManager.getInstance(new LocalStorageConfigStore())
const config = ref({
theme: 'light',
language: 'zh-CN',
autoSave: true,
notifications: true
})
// 处理配置更改 // 防抖函数
const handleConfigChange = () => { const debounce = (fn: Function, delay: number) => {
// 这里可以添加保存配置的逻辑 let timer: number | null = null
console.log('配置已更新:', config.value) return (...args: any[]) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
timer = null
}, delay)
}
} }
// 处理文本配置项实时更新
const handleTextConfigChange = debounce((item: TextConfigItem, group: ConfigGroup) => {
configManager.updateConfig({
[`${group.key}.${item.key}`]: item.value
})
console.log('配置已更新:', configManager.getAllConfig())
}, 300)
// 处理单个配置项更改
const handleConfigItemChange = (item: any, group: ConfigGroup) => {
configManager.updateConfig({
[`${group.key}.${item.key}`]: item.value
})
console.log('配置已更新:', configManager.getAllConfig())
}
// 处理配置组启用状态变更
const handleGroupEnableChange = (group: ConfigGroup) => {
configManager.updateConfig({
[`${group.key}.enabled`]: group.enabled
})
console.log('配置已更新:', configManager.getAllConfig())
}
// 使用构造函数定义配置组
const configGroups = ref([
new ConfigGroup('appearance', '外观设置', false, [
new TextConfigItem(
'projectName',
'项目名称',
'设置新建项目时的默认项目名称',
'',
'请输入默认项目名称'
),
new SelectConfigItem(
'theme',
'主题',
'切换应用的显示主题',
[
{ label: '浅色', value: 'light' },
{ label: '深色', value: 'dark' }
],
'light'
)
]),
new ConfigGroup('system', '系统设置', true, [
new SelectConfigItem(
'language',
'语言',
'设置应用界面显示的语言',
[
{ label: '中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' }
],
'zh-CN'
),
new SwitchConfigItem(
'autoSave',
'自动保存',
'开启后将自动保存您的修改',
true
),
new SwitchConfigItem(
'notifications',
'通知提醒',
'是否显示系统通知提醒',
true
)
])
])
</script> </script>
<template> <template>
<div class="config-container"> <div class="config-container">
<h2 class="config-title">系统设置</h2> <h2 class="config-title">系统设置</h2>
<div class="config-section"> <div
<div class="config-item"> v-for="(group, groupIndex) in configGroups"
<label>主题</label> :key="groupIndex"
<select v-model="config.theme"> class="config-section"
<option value="light">浅色</option> >
<option value="dark">深色</option> <div class="group-header">
</select> <h3 class="group-title">{{ group.title }}</h3>
<label class="switch">
<input
type="checkbox"
v-model="group.enabled"
@change="handleGroupEnableChange(group)"
>
<span class="slider"></span>
</label>
</div> </div>
<div class="config-item"> <div
<label>语言</label> v-if="group.enabled"
<select v-model="config.language"> v-for="(item, itemIndex) in group.items"
<option value="zh-CN">中文</option> :key="itemIndex"
<option value="en-US">English</option> class="config-item"
</select>
</div>
<div class="config-item">
<label>自动保存</label>
<input
type="checkbox"
v-model="config.autoSave"
>
</div>
<div class="config-item">
<label>通知提醒</label>
<input
type="checkbox"
v-model="config.notifications"
>
</div>
</div>
<div class="config-actions">
<button
class="save-button"
@click="handleConfigChange"
> >
保存设置 <div class="item-info">
</button> <div class="item-label">{{ item.label }}</div>
<div v-if="item.description" class="item-description">
{{ item.description }}
</div>
</div>
<!-- 文本输入 -->
<div v-if="item.type === ConfigItemType.TEXT" class="input-wrapper">
<input
type="text"
v-model="item.value"
:placeholder="(item as any).placeholder"
@input="handleTextConfigChange(item, group)"
>
</div>
<!-- 开关 -->
<label v-else-if="item.type === ConfigItemType.SWITCH" class="switch">
<input
type="checkbox"
v-model="item.value"
@change="handleConfigItemChange(item, group)"
>
<span class="slider"></span>
</label>
<!-- 下拉选择 -->
<div v-else-if="item.type === ConfigItemType.SELECT" class="select-wrapper">
<select
v-model="item.value"
@change="handleConfigItemChange(item, group)"
>
<option
v-for="option in (item as any).options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<div class="select-arrow"></div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -82,8 +182,21 @@ const handleConfigChange = () => {
.config-section { .config-section {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.group-title {
font-size: 18px;
color: #333;
} }
.config-item { .config-item {
@@ -91,43 +204,114 @@ const handleConfigChange = () => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #eee;
} }
.config-item:last-child { .item-info {
border-bottom: none; flex: 1;
margin-right: 24px;
} }
.config-item label { .item-label {
font-size: 14px; font-size: 14px;
color: #333; color: rgba(0, 0, 0, 0.87);
} }
.config-item select { .item-description {
padding: 6px 12px; font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
line-height: 1.5;
}
/* 输入框通用包装器 */
.input-wrapper {
position: relative;
width: 200px;
}
.input-wrapper input {
width: 100%;
padding: 8px 12px;
outline: none;
font-size: 14px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-size: 14px; transition: all 0.2s;
min-width: 120px; box-sizing: border-box;
} }
.config-actions { .input-wrapper input:focus {
margin-top: 24px; border-color: #1976d2;
text-align: right;
} }
.save-button { /* 开关样式 */
background: #1976d2; .switch {
color: white; position: relative;
border: none; display: inline-block;
padding: 8px 24px; width: 40px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.switch input:checked + .slider {
background-color: #1976d2;
}
.switch input:checked + .slider:before {
transform: translateX(20px);
background-color: white;
}
/* 下拉选择框样式 */
.select-wrapper {
position: relative;
width: 200px;
}
.select-wrapper select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
appearance: none;
background: white;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; outline: none;
transition: all 0.2s;
} }
.save-button:hover { .select-wrapper select:focus {
background: #1565c0; border-color: #1976d2;
} }
</style> </style>

View File

@@ -26,7 +26,7 @@ const initListData = () => {
action: (item) => { action: (item) => {
console.log('打开项目:', item?.name) console.log('打开项目:', item?.name)
}, },
shortcut: 'ctrl+o' shortcut: 'alt+o'
}, },
{ {
id: 'edit', id: 'edit',

74
src/types/config.ts Normal file
View File

@@ -0,0 +1,74 @@
// 配置项类型枚举
export enum ConfigItemType {
TEXT = 'text',
SWITCH = 'switch',
SELECT = 'select'
}
// 基础配置项接口
export interface BaseConfigItem {
type: ConfigItemType
key: string
label: string
value: any
description: string
}
// 文本配置项
export class TextConfigItem implements BaseConfigItem {
readonly type = ConfigItemType.TEXT
value: string
constructor(
public key: string,
public label: string,
public description: string,
defaultValue: string = '',
public placeholder?: string
) {
this.value = defaultValue
}
}
// 开关配置项
export class SwitchConfigItem implements BaseConfigItem {
readonly type = ConfigItemType.SWITCH
value: boolean
constructor(
public key: string,
public label: string,
public description: string,
defaultValue: boolean = false
) {
this.value = defaultValue
}
}
// 选择配置项
export class SelectConfigItem implements BaseConfigItem {
readonly type = ConfigItemType.SELECT
value: string
constructor(
public key: string,
public label: string,
public description: string,
public options: Array<{ label: string; value: string }>,
defaultValue: string
) {
this.value = defaultValue
}
}
export type ConfigItem = TextConfigItem | SelectConfigItem | SwitchConfigItem
// 配置组
export class ConfigGroup {
constructor(
public key: string,
public title: string,
public enabled: boolean,
public items: ConfigItem[]
) {}
}

98
src/utils/ConfigStore.ts Normal file
View File

@@ -0,0 +1,98 @@
// 配置存储接口
export interface ConfigStore {
get<T>(key: string): T | undefined
set<T>(key: string, value: T): void
getAll(): Record<string, any>
clear(): void
}
// 内存存储实现
export class MemoryConfigStore implements ConfigStore {
private store: Record<string, any> = {}
get<T>(key: string): T | undefined {
return this.store[key] as T
}
set<T>(key: string, value: T): void {
this.store[key] = value
}
getAll(): Record<string, any> {
return { ...this.store }
}
clear(): void {
this.store = {}
}
}
// LocalStorage存储实现
export class LocalStorageConfigStore implements ConfigStore {
private readonly prefix = 'app_config.'
private getKey(key: string): string {
return this.prefix + key
}
get<T>(key: string): T | undefined {
const value = localStorage.getItem(this.getKey(key))
return value ? JSON.parse(value) : undefined
}
set<T>(key: string, value: T): void {
localStorage.setItem(this.getKey(key), JSON.stringify(value))
}
getAll(): Record<string, any> {
const result: Record<string, any> = {}
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(this.prefix)) {
const realKey = key.slice(this.prefix.length)
result[realKey] = JSON.parse(localStorage.getItem(key) || '{}')
}
}
return result
}
clear(): void {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i)
if (key?.startsWith(this.prefix)) {
localStorage.removeItem(key)
}
}
}
}
// 配置管理器
export class ConfigManager {
private static instance: ConfigManager
private store: ConfigStore
private constructor(store: ConfigStore) {
this.store = store
}
static getInstance(store: ConfigStore = new MemoryConfigStore()): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager(store)
}
return ConfigManager.instance
}
updateConfig(config: Record<string, any>): void {
Object.entries(config).forEach(([key, value]) => {
this.store.set(key, value)
})
}
getConfig<T>(key: string): T | undefined {
return this.store.get<T>(key)
}
getAllConfig(): Record<string, any> {
return this.store.getAll()
}
}

View File

@@ -0,0 +1,116 @@
type KeyboardCallback = (e: KeyboardEvent) => void
export class KeyboardManager {
private shortcuts: Map<string, KeyboardCallback> = new Map()
private pressedKeys: Set<string> = new Set()
private enabled: boolean = false
constructor() {
this.handleKeyDown = this.handleKeyDown.bind(this)
this.handleKeyUp = this.handleKeyUp.bind(this)
}
/**
* 启用快捷键监听
*/
enable(): void {
if (!this.enabled) {
window.addEventListener('keydown', this.handleKeyDown)
window.addEventListener('keyup', this.handleKeyUp)
this.enabled = true
}
}
/**
* 禁用快捷键监听
*/
disable(): void {
if (this.enabled) {
window.removeEventListener('keydown', this.handleKeyDown)
window.removeEventListener('keyup', this.handleKeyUp)
this.enabled = false
this.pressedKeys.clear()
}
}
/**
* 绑定快捷键
*/
bind(shortcut: string, callback: KeyboardCallback): void {
this.shortcuts.set(this.normalizeShortcut(shortcut), callback)
}
/**
* 解绑快捷键
*/
unbind(shortcut: string): void {
this.shortcuts.delete(this.normalizeShortcut(shortcut))
}
/**
* 清除所有快捷键绑定
*/
clear(): void {
this.shortcuts.clear()
this.pressedKeys.clear()
}
private handleKeyDown(e: KeyboardEvent): void {
// 忽略在输入框中的按键
if (this.shouldIgnoreInput(e)) {
return
}
const key = this.normalizeKey(e.key.toLowerCase())
this.pressedKeys.add(key)
const currentShortcut = Array.from(this.pressedKeys).sort().join('+')
const callback = this.shortcuts.get(currentShortcut)
if (callback) {
e.preventDefault()
e.stopPropagation()
callback(e)
}
}
private handleKeyUp(e: KeyboardEvent): void {
const key = this.normalizeKey(e.key.toLowerCase())
this.pressedKeys.delete(key)
}
private normalizeKey(key: string): string {
const keyMap: Record<string, string> = {
'control': 'ctrl',
'command': 'cmd',
'meta': 'cmd',
'escape': 'esc',
' ': 'space',
'arrowup': 'up',
'arrowdown': 'down',
'arrowleft': 'left',
'arrowright': 'right',
}
return keyMap[key] || key
}
private normalizeShortcut(shortcut: string): string {
return shortcut
.toLowerCase()
.split('+')
.map(key => this.normalizeKey(key.trim()))
.sort()
.join('+')
}
private shouldIgnoreInput(e: KeyboardEvent): boolean {
const element = e.target as HTMLElement
return element.tagName === 'INPUT' ||
element.tagName === 'TEXTAREA' ||
element.tagName === 'SELECT' ||
element.isContentEditable
}
}
// 导出单例实例
export const keyboardManager = new KeyboardManager()

View File

@@ -23,8 +23,7 @@
] ]
}, },
"types": [ "types": [
"vue-router", "vue-router"
"mousetrap"
] ]
}, },
"include": [ "include": [