feat: 新增 hotkeys 子包,统一维护全局快捷键

This commit is contained in:
Hooray
2026-06-24 03:13:24 +08:00
parent ee62670209
commit 5bbe23df3e
118 changed files with 1524 additions and 3785 deletions
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@vee-validate/zod": "catalog:",
@@ -28,7 +29,6 @@
"dayjs": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
@@ -0,0 +1,41 @@
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
} from '@fantastic-admin/hotkeys'
import { createHotkeys } from '@fantastic-admin/hotkeys'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './extend'
const appHotkeys = createHotkeys({
extendIds: EXT_HOTKEY_ID,
extendGlobalHotkeyBindings,
extendScopedHotkeyBindings,
getContext: () => ({
settings: useAppSettingsStore().settings,
}),
})
export const HOTKEY_ID = appHotkeys.HOTKEY_ID
export const hotkeyIds = appHotkeys.hotkeyIds
export const globalHotkeyBindings = appHotkeys.globalHotkeyBindings
export const menuSearchHotkeyBindings = appHotkeys.menuSearchHotkeyBindings
export const hotkeyBindings = appHotkeys.hotkeyBindings
export const useHotkey = appHotkeys.useHotkey
export const useHotkeyBindings = appHotkeys.useHotkeyBindings
export type HotkeyId = typeof HOTKEY_ID[keyof typeof HOTKEY_ID]
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
}
@@ -1,22 +0,0 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
@@ -1,210 +0,0 @@
import type { HotkeyBinding } from './types'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
export const HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
...EXT_HOTKEY_ID,
} as const
export const hotkeyIds = Object.values(HOTKEY_ID)
/**
* Hotkeys registry 维护约定
*
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const globalHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: HOTKEY_ID.pageReload,
keys: ['f5'],
},
...extendGlobalHotkeyBindings,
]
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
export const hotkeyBindings: HotkeyBinding[] = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
]
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
@@ -1,38 +0,0 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
@@ -1,69 +0,0 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import { hotkeyBindings } from '@/hotkeys'
import eventBus from '@/utils/eventBus'
defineOptions({
@@ -11,10 +11,10 @@ const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
const helpGroupMeta = {
global: { title: '全局', order: 10 },
nav: { title: '主导航', order: 20 },
tabbar: { title: '标签栏', order: 30 },
} as const
const itemTitleMap: Record<string, string> = {
@@ -32,42 +32,52 @@ const itemTitleMap: Record<string, string> = {
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
const groups = new Map<string, {
id: string
title: string
order: number
items: {
id: string
title: string
order: number
displayKeys: string[]
}[]
}>()
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
hotkeyBindings.forEach((binding) => {
const help = binding.help
if (!help) {
return
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
if (help.visible && !help.visible(hotkeyContext)) {
return
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
const meta = helpGroupMeta[help.group as keyof typeof helpGroupMeta]
const group = groups.get(help.group) ?? {
id: help.group,
title: help.groupTitleKey ? itemTitleMap[help.groupTitleKey] ?? help.groupTitleKey : meta?.title ?? help.group,
order: meta?.order ?? 100,
items: [],
}
group.items.push({
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
title: itemTitleMap[help.titleKey] ?? help.titleKey,
order: help.order ?? 0,
displayKeys: appSettingsStore.os === 'mac' && help.displayKeys.mac
? help.displayKeys.mac
: help.displayKeys.default,
})
groups.set(help.group, group)
})
return {
return Array.from(groups.values())
.map(group => ({
...group,
items,
}
}).filter(group => group.items.length > 0)
items: [...group.items].sort((a, b) => a.order - b.order),
}))
.sort((a, b) => a.order - b.order)
})
onMounted(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
defineOptions({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Search from './search.vue'
defineOptions({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@vee-validate/zod": "catalog:",
@@ -28,7 +29,6 @@
"dayjs": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
-107
View File
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
+41
View File
@@ -0,0 +1,41 @@
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
} from '@fantastic-admin/hotkeys'
import { createHotkeys } from '@fantastic-admin/hotkeys'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './extend'
const appHotkeys = createHotkeys({
extendIds: EXT_HOTKEY_ID,
extendGlobalHotkeyBindings,
extendScopedHotkeyBindings,
getContext: () => ({
settings: useAppSettingsStore().settings,
}),
})
export const HOTKEY_ID = appHotkeys.HOTKEY_ID
export const hotkeyIds = appHotkeys.hotkeyIds
export const globalHotkeyBindings = appHotkeys.globalHotkeyBindings
export const menuSearchHotkeyBindings = appHotkeys.menuSearchHotkeyBindings
export const hotkeyBindings = appHotkeys.hotkeyBindings
export const useHotkey = appHotkeys.useHotkey
export const useHotkeyBindings = appHotkeys.useHotkeyBindings
export type HotkeyId = typeof HOTKEY_ID[keyof typeof HOTKEY_ID]
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
}
@@ -1,22 +0,0 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
-38
View File
@@ -1,38 +0,0 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
@@ -1,69 +0,0 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import { hotkeyBindings } from '@/hotkeys'
import eventBus from '@/utils/eventBus'
defineOptions({
@@ -11,10 +11,10 @@ const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
const helpGroupMeta = {
global: { title: '全局', order: 10 },
nav: { title: '主导航', order: 20 },
tabbar: { title: '标签栏', order: 30 },
} as const
const itemTitleMap: Record<string, string> = {
@@ -32,42 +32,52 @@ const itemTitleMap: Record<string, string> = {
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
const groups = new Map<string, {
id: string
title: string
order: number
items: {
id: string
title: string
order: number
displayKeys: string[]
}[]
}>()
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
hotkeyBindings.forEach((binding) => {
const help = binding.help
if (!help) {
return
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
if (help.visible && !help.visible(hotkeyContext)) {
return
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
const meta = helpGroupMeta[help.group as keyof typeof helpGroupMeta]
const group = groups.get(help.group) ?? {
id: help.group,
title: help.groupTitleKey ? itemTitleMap[help.groupTitleKey] ?? help.groupTitleKey : meta?.title ?? help.group,
order: meta?.order ?? 100,
items: [],
}
group.items.push({
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
title: itemTitleMap[help.titleKey] ?? help.titleKey,
order: help.order ?? 0,
displayKeys: appSettingsStore.os === 'mac' && help.displayKeys.mac
? help.displayKeys.mac
: help.displayKeys.default,
})
groups.set(help.group, group)
})
return {
return Array.from(groups.values())
.map(group => ({
...group,
items,
}
}).filter(group => group.items.length > 0)
items: [...group.items].sort((a, b) => a.order - b.order),
}))
.sort((a, b) => a.order - b.order)
})
onMounted(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
defineOptions({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Search from './search.vue'
defineOptions({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@vee-validate/zod": "catalog:",
@@ -27,7 +28,6 @@
"dayjs": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
@@ -0,0 +1,41 @@
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
} from '@fantastic-admin/hotkeys'
import { createHotkeys } from '@fantastic-admin/hotkeys'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './extend'
const appHotkeys = createHotkeys({
extendIds: EXT_HOTKEY_ID,
extendGlobalHotkeyBindings,
extendScopedHotkeyBindings,
getContext: () => ({
settings: useAppSettingsStore().settings,
}),
})
export const HOTKEY_ID = appHotkeys.HOTKEY_ID
export const hotkeyIds = appHotkeys.hotkeyIds
export const globalHotkeyBindings = appHotkeys.globalHotkeyBindings
export const menuSearchHotkeyBindings = appHotkeys.menuSearchHotkeyBindings
export const hotkeyBindings = appHotkeys.hotkeyBindings
export const useHotkey = appHotkeys.useHotkey
export const useHotkeyBindings = appHotkeys.useHotkeyBindings
export type HotkeyId = typeof HOTKEY_ID[keyof typeof HOTKEY_ID]
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
}
@@ -1,22 +0,0 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
@@ -1,210 +0,0 @@
import type { HotkeyBinding } from './types'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
export const HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
...EXT_HOTKEY_ID,
} as const
export const hotkeyIds = Object.values(HOTKEY_ID)
/**
* Hotkeys registry 维护约定
*
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const globalHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: HOTKEY_ID.pageReload,
keys: ['f5'],
},
...extendGlobalHotkeyBindings,
]
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
export const hotkeyBindings: HotkeyBinding[] = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
]
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
@@ -1,38 +0,0 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
@@ -1,69 +0,0 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import { hotkeyBindings } from '@/hotkeys'
import eventBus from '@/utils/eventBus'
defineOptions({
@@ -11,10 +11,10 @@ const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
const helpGroupMeta = {
global: { title: '全局', order: 10 },
nav: { title: '主导航', order: 20 },
tabbar: { title: '标签栏', order: 30 },
} as const
const itemTitleMap: Record<string, string> = {
@@ -32,42 +32,52 @@ const itemTitleMap: Record<string, string> = {
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
const groups = new Map<string, {
id: string
title: string
order: number
items: {
id: string
title: string
order: number
displayKeys: string[]
}[]
}>()
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
hotkeyBindings.forEach((binding) => {
const help = binding.help
if (!help) {
return
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
if (help.visible && !help.visible(hotkeyContext)) {
return
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
const meta = helpGroupMeta[help.group as keyof typeof helpGroupMeta]
const group = groups.get(help.group) ?? {
id: help.group,
title: help.groupTitleKey ? itemTitleMap[help.groupTitleKey] ?? help.groupTitleKey : meta?.title ?? help.group,
order: meta?.order ?? 100,
items: [],
}
group.items.push({
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
title: itemTitleMap[help.titleKey] ?? help.titleKey,
order: help.order ?? 0,
displayKeys: appSettingsStore.os === 'mac' && help.displayKeys.mac
? help.displayKeys.mac
: help.displayKeys.default,
})
groups.set(help.group, group)
})
return {
return Array.from(groups.values())
.map(group => ({
...group,
items,
}
}).filter(group => group.items.length > 0)
items: [...group.items].sort((a, b) => a.order - b.order),
}))
.sort((a, b) => a.order - b.order)
})
onMounted(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
defineOptions({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Search from './search.vue'
defineOptions({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@vee-validate/zod": "catalog:",
@@ -27,7 +28,6 @@
"dayjs": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
-107
View File
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
+57
View File
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
+41
View File
@@ -0,0 +1,41 @@
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
} from '@fantastic-admin/hotkeys'
import { createHotkeys } from '@fantastic-admin/hotkeys'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './extend'
const appHotkeys = createHotkeys({
extendIds: EXT_HOTKEY_ID,
extendGlobalHotkeyBindings,
extendScopedHotkeyBindings,
getContext: () => ({
settings: useAppSettingsStore().settings,
}),
})
export const HOTKEY_ID = appHotkeys.HOTKEY_ID
export const hotkeyIds = appHotkeys.hotkeyIds
export const globalHotkeyBindings = appHotkeys.globalHotkeyBindings
export const menuSearchHotkeyBindings = appHotkeys.menuSearchHotkeyBindings
export const hotkeyBindings = appHotkeys.hotkeyBindings
export const useHotkey = appHotkeys.useHotkey
export const useHotkeyBindings = appHotkeys.useHotkeyBindings
export type HotkeyId = typeof HOTKEY_ID[keyof typeof HOTKEY_ID]
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
}
@@ -1,22 +0,0 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
-210
View File
@@ -1,210 +0,0 @@
import type { HotkeyBinding } from './types'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
export const HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
...EXT_HOTKEY_ID,
} as const
export const hotkeyIds = Object.values(HOTKEY_ID)
/**
* Hotkeys registry 维护约定
*
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const globalHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: HOTKEY_ID.pageReload,
keys: ['f5'],
},
...extendGlobalHotkeyBindings,
]
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
export const hotkeyBindings: HotkeyBinding[] = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
]
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
-38
View File
@@ -1,38 +0,0 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
@@ -1,69 +0,0 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import { hotkeyBindings } from '@/hotkeys'
import eventBus from '@/utils/eventBus'
defineOptions({
@@ -11,10 +11,10 @@ const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
const helpGroupMeta = {
global: { title: '全局', order: 10 },
nav: { title: '主导航', order: 20 },
tabbar: { title: '标签栏', order: 30 },
} as const
const itemTitleMap: Record<string, string> = {
@@ -32,42 +32,52 @@ const itemTitleMap: Record<string, string> = {
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
const groups = new Map<string, {
id: string
title: string
order: number
items: {
id: string
title: string
order: number
displayKeys: string[]
}[]
}>()
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
hotkeyBindings.forEach((binding) => {
const help = binding.help
if (!help) {
return
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
if (help.visible && !help.visible(hotkeyContext)) {
return
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
const meta = helpGroupMeta[help.group as keyof typeof helpGroupMeta]
const group = groups.get(help.group) ?? {
id: help.group,
title: help.groupTitleKey ? itemTitleMap[help.groupTitleKey] ?? help.groupTitleKey : meta?.title ?? help.group,
order: meta?.order ?? 100,
items: [],
}
group.items.push({
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
title: itemTitleMap[help.titleKey] ?? help.titleKey,
order: help.order ?? 0,
displayKeys: appSettingsStore.os === 'mac' && help.displayKeys.mac
? help.displayKeys.mac
: help.displayKeys.default,
})
groups.set(help.group, group)
})
return {
return Array.from(groups.values())
.map(group => ({
...group,
items,
}
}).filter(group => group.items.length > 0)
items: [...group.items].sort((a, b) => a.order - b.order),
}))
.sort((a, b) => a.order - b.order)
})
onMounted(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
defineOptions({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Search from './search.vue'
defineOptions({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@vee-validate/zod": "catalog:",
@@ -27,7 +28,6 @@
"dayjs": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
-107
View File
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
+57
View File
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
+41
View File
@@ -0,0 +1,41 @@
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
} from '@fantastic-admin/hotkeys'
import { createHotkeys } from '@fantastic-admin/hotkeys'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './extend'
const appHotkeys = createHotkeys({
extendIds: EXT_HOTKEY_ID,
extendGlobalHotkeyBindings,
extendScopedHotkeyBindings,
getContext: () => ({
settings: useAppSettingsStore().settings,
}),
})
export const HOTKEY_ID = appHotkeys.HOTKEY_ID
export const hotkeyIds = appHotkeys.hotkeyIds
export const globalHotkeyBindings = appHotkeys.globalHotkeyBindings
export const menuSearchHotkeyBindings = appHotkeys.menuSearchHotkeyBindings
export const hotkeyBindings = appHotkeys.hotkeyBindings
export const useHotkey = appHotkeys.useHotkey
export const useHotkeyBindings = appHotkeys.useHotkeyBindings
export type HotkeyId = typeof HOTKEY_ID[keyof typeof HOTKEY_ID]
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
}
@@ -1,22 +0,0 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
-210
View File
@@ -1,210 +0,0 @@
import type { HotkeyBinding } from './types'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
export const HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
...EXT_HOTKEY_ID,
} as const
export const hotkeyIds = Object.values(HOTKEY_ID)
/**
* Hotkeys registry 维护约定
*
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const globalHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: HOTKEY_ID.pageReload,
keys: ['f5'],
},
...extendGlobalHotkeyBindings,
]
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
export const hotkeyBindings: HotkeyBinding[] = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
]
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
-38
View File
@@ -1,38 +0,0 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
@@ -1,69 +0,0 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import { hotkeyBindings } from '@/hotkeys'
import eventBus from '@/utils/eventBus'
defineOptions({
@@ -11,10 +11,10 @@ const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
const helpGroupMeta = {
global: { title: '全局', order: 10 },
nav: { title: '主导航', order: 20 },
tabbar: { title: '标签栏', order: 30 },
} as const
const itemTitleMap: Record<string, string> = {
@@ -32,42 +32,52 @@ const itemTitleMap: Record<string, string> = {
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
const groups = new Map<string, {
id: string
title: string
order: number
items: {
id: string
title: string
order: number
displayKeys: string[]
}[]
}>()
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
hotkeyBindings.forEach((binding) => {
const help = binding.help
if (!help) {
return
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
if (help.visible && !help.visible(hotkeyContext)) {
return
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
const meta = helpGroupMeta[help.group as keyof typeof helpGroupMeta]
const group = groups.get(help.group) ?? {
id: help.group,
title: help.groupTitleKey ? itemTitleMap[help.groupTitleKey] ?? help.groupTitleKey : meta?.title ?? help.group,
order: meta?.order ?? 100,
items: [],
}
group.items.push({
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
title: itemTitleMap[help.titleKey] ?? help.titleKey,
order: help.order ?? 0,
displayKeys: appSettingsStore.os === 'mac' && help.displayKeys.mac
? help.displayKeys.mac
: help.displayKeys.default,
})
groups.set(help.group, group)
})
return {
return Array.from(groups.values())
.map(group => ({
...group,
items,
}
}).filter(group => group.items.length > 0)
items: [...group.items].sort((a, b) => a.order - b.order),
}))
.sort((a, b) => a.order - b.order)
})
onMounted(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
defineOptions({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Search from './search.vue'
defineOptions({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@vee-validate/zod": "catalog:",
@@ -27,7 +28,6 @@
"dayjs": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
-107
View File
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
+57
View File
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
+41
View File
@@ -0,0 +1,41 @@
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
} from '@fantastic-admin/hotkeys'
import { createHotkeys } from '@fantastic-admin/hotkeys'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './extend'
const appHotkeys = createHotkeys({
extendIds: EXT_HOTKEY_ID,
extendGlobalHotkeyBindings,
extendScopedHotkeyBindings,
getContext: () => ({
settings: useAppSettingsStore().settings,
}),
})
export const HOTKEY_ID = appHotkeys.HOTKEY_ID
export const hotkeyIds = appHotkeys.hotkeyIds
export const globalHotkeyBindings = appHotkeys.globalHotkeyBindings
export const menuSearchHotkeyBindings = appHotkeys.menuSearchHotkeyBindings
export const hotkeyBindings = appHotkeys.hotkeyBindings
export const useHotkey = appHotkeys.useHotkey
export const useHotkeyBindings = appHotkeys.useHotkeyBindings
export type HotkeyId = typeof HOTKEY_ID[keyof typeof HOTKEY_ID]
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
}
@@ -1,22 +0,0 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
-210
View File
@@ -1,210 +0,0 @@
import type { HotkeyBinding } from './types'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
export const HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
...EXT_HOTKEY_ID,
} as const
export const hotkeyIds = Object.values(HOTKEY_ID)
/**
* Hotkeys registry 维护约定
*
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const globalHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: HOTKEY_ID.pageReload,
keys: ['f5'],
},
...extendGlobalHotkeyBindings,
]
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
export const hotkeyBindings: HotkeyBinding[] = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
]
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
-38
View File
@@ -1,38 +0,0 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
@@ -1,69 +0,0 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import { hotkeyBindings } from '@/hotkeys'
import eventBus from '@/utils/eventBus'
defineOptions({
@@ -11,10 +11,10 @@ const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
const helpGroupMeta = {
global: { title: '全局', order: 10 },
nav: { title: '主导航', order: 20 },
tabbar: { title: '标签栏', order: 30 },
} as const
const itemTitleMap: Record<string, string> = {
@@ -32,42 +32,52 @@ const itemTitleMap: Record<string, string> = {
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
const groups = new Map<string, {
id: string
title: string
order: number
items: {
id: string
title: string
order: number
displayKeys: string[]
}[]
}>()
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
hotkeyBindings.forEach((binding) => {
const help = binding.help
if (!help) {
return
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
if (help.visible && !help.visible(hotkeyContext)) {
return
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
const meta = helpGroupMeta[help.group as keyof typeof helpGroupMeta]
const group = groups.get(help.group) ?? {
id: help.group,
title: help.groupTitleKey ? itemTitleMap[help.groupTitleKey] ?? help.groupTitleKey : meta?.title ?? help.group,
order: meta?.order ?? 100,
items: [],
}
group.items.push({
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
title: itemTitleMap[help.titleKey] ?? help.titleKey,
order: help.order ?? 0,
displayKeys: appSettingsStore.os === 'mac' && help.displayKeys.mac
? help.displayKeys.mac
: help.displayKeys.default,
})
groups.set(help.group, group)
})
return {
return Array.from(groups.values())
.map(group => ({
...group,
items,
}
}).filter(group => group.items.length > 0)
items: [...group.items].sort((a, b) => a.order - b.order),
}))
.sort((a, b) => a.order - b.order)
})
onMounted(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
defineOptions({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Search from './search.vue'
defineOptions({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@vee-validate/zod": "catalog:",
@@ -28,7 +29,6 @@
"element-plus": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
-107
View File
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
+57
View File
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
+41
View File
@@ -0,0 +1,41 @@
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
} from '@fantastic-admin/hotkeys'
import { createHotkeys } from '@fantastic-admin/hotkeys'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './extend'
const appHotkeys = createHotkeys({
extendIds: EXT_HOTKEY_ID,
extendGlobalHotkeyBindings,
extendScopedHotkeyBindings,
getContext: () => ({
settings: useAppSettingsStore().settings,
}),
})
export const HOTKEY_ID = appHotkeys.HOTKEY_ID
export const hotkeyIds = appHotkeys.hotkeyIds
export const globalHotkeyBindings = appHotkeys.globalHotkeyBindings
export const menuSearchHotkeyBindings = appHotkeys.menuSearchHotkeyBindings
export const hotkeyBindings = appHotkeys.hotkeyBindings
export const useHotkey = appHotkeys.useHotkey
export const useHotkeyBindings = appHotkeys.useHotkeyBindings
export type HotkeyId = typeof HOTKEY_ID[keyof typeof HOTKEY_ID]
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
}
-22
View File
@@ -1,22 +0,0 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
-210
View File
@@ -1,210 +0,0 @@
import type { HotkeyBinding } from './types'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
export const HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
...EXT_HOTKEY_ID,
} as const
export const hotkeyIds = Object.values(HOTKEY_ID)
/**
* Hotkeys registry 维护约定
*
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const globalHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: HOTKEY_ID.pageReload,
keys: ['f5'],
},
...extendGlobalHotkeyBindings,
]
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
export const hotkeyBindings: HotkeyBinding[] = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
]
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
-38
View File
@@ -1,38 +0,0 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
-69
View File
@@ -1,69 +0,0 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import { hotkeyBindings } from '@/hotkeys'
import eventBus from '@/utils/eventBus'
defineOptions({
@@ -11,10 +11,10 @@ const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
const helpGroupMeta = {
global: { title: '全局', order: 10 },
nav: { title: '主导航', order: 20 },
tabbar: { title: '标签栏', order: 30 },
} as const
const itemTitleMap: Record<string, string> = {
@@ -32,42 +32,52 @@ const itemTitleMap: Record<string, string> = {
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
const groups = new Map<string, {
id: string
title: string
order: number
items: {
id: string
title: string
order: number
displayKeys: string[]
}[]
}>()
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
hotkeyBindings.forEach((binding) => {
const help = binding.help
if (!help) {
return
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
if (help.visible && !help.visible(hotkeyContext)) {
return
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
const meta = helpGroupMeta[help.group as keyof typeof helpGroupMeta]
const group = groups.get(help.group) ?? {
id: help.group,
title: help.groupTitleKey ? itemTitleMap[help.groupTitleKey] ?? help.groupTitleKey : meta?.title ?? help.group,
order: meta?.order ?? 100,
items: [],
}
group.items.push({
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
title: itemTitleMap[help.titleKey] ?? help.titleKey,
order: help.order ?? 0,
displayKeys: appSettingsStore.os === 'mac' && help.displayKeys.mac
? help.displayKeys.mac
: help.displayKeys.default,
})
groups.set(help.group, group)
})
return {
return Array.from(groups.values())
.map(group => ({
...group,
items,
}
}).filter(group => group.items.length > 0)
items: [...group.items].sort((a, b) => a.order - b.order),
}))
.sort((a, b) => a.order - b.order)
})
onMounted(() => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
defineOptions({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Search from './search.vue'
defineOptions({
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useHotkeyBindings } from '@/hotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
+1 -1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@fantastic-admin/components": "workspace:*",
"@fantastic-admin/composables": "workspace:*",
"@fantastic-admin/hotkeys": "workspace:*",
"@fantastic-admin/settings": "workspace:*",
"@fantastic-admin/types": "workspace:*",
"@tinymce/tinymce-vue": "catalog:",
@@ -33,7 +34,6 @@
"element-plus": "catalog:",
"eruda": "catalog:",
"es-toolkit": "catalog:",
"hotkeys-js": "catalog:",
"mitt": "catalog:",
"nprogress": "catalog:",
"path-browserify": "catalog:",
-107
View File
@@ -1,107 +0,0 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
+57
View File
@@ -0,0 +1,57 @@
import type { HotkeyBinding } from '@fantastic-admin/hotkeys'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
type ExtendHotkeyId = typeof EXT_HOTKEY_ID[keyof typeof EXT_HOTKEY_ID]
/**
* 业务扩展全局快捷键
*
* 示例:
*
* export const extendGlobalHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoOpen,
* keys: ['command+j', 'ctrl+j'],
* enabled: ctx => ctx.settings.toolbar.menuSearch.enable,
* help: {
* group: 'global',
* titleKey: 'global.demoOpen',
* order: 90,
* visible: ctx => ctx.settings.toolbar.menuSearch.enable,
* displayKeys: {
* default: ['Ctrl', 'J'],
* mac: ['⌘', 'J'],
* },
* },
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendGlobalHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]
/**
* 业务扩展局部快捷键
*
* 示例:
*
* export const extendScopedHotkeyBindings = [
* {
* id: EXT_HOTKEY_ID.demoConfirm,
* keys: ['enter'],
* },
* {
* id: EXT_HOTKEY_ID.demoClose,
* keys: ['esc'],
* },
* ] satisfies HotkeyBinding<ExtendHotkeyId>[]
*/
export const extendScopedHotkeyBindings = [] satisfies HotkeyBinding<ExtendHotkeyId>[]

Some files were not shown because too many files have changed in this diff Show More