Compare commits

...

28 Commits

Author SHA1 Message Date
57a669df71 增加配置项保存的实现 2024-12-13 09:36:12 +08:00
f8df3d2681 优化配置界面 2024-12-13 00:30:03 +08:00
8f1964c374 移除对 Mousetrap 的依赖,改为使用自定义的 KeyboardManager 进行快捷键绑定和管理,同时更新相关类型定义,调整菜单项的快捷键配置逻辑。 2024-12-12 23:35:39 +08:00
90ccd8d6d0 修复mousetrap没有类型提示的问题 2024-12-12 22:52:53 +08:00
45a5c581f9 增加空白列表的提示 2024-12-12 22:50:45 +08:00
01d89e0303 添加 Mousetrap 支持以实现菜单项的快捷键绑定,优化菜单项的显示逻辑和键盘导航体验,同时更新相关类型定义以支持菜单项的快捷键配置。 2024-12-12 22:48:13 +08:00
8bf80c2f31 优化菜单项的操作逻辑 2024-12-12 22:18:38 +08:00
b7a939ee85 优化首尾跳转的响应逻辑,当方向键一直按住的时候不触发跳转直到再点一次 2024-12-12 22:05:44 +08:00
20b9ad37ed 鼠标不响应首尾项对齐 2024-12-12 21:58:32 +08:00
18aee54570 使用代码控制加载的链接 2024-12-11 17:52:49 +08:00
28c13d98e0 修复vue-router的类型提示 2024-12-11 17:46:53 +08:00
e8b6e165bd 增加vue-router来控制页面显示 2024-12-11 17:42:32 +08:00
c0bae2c1bb 拆分主要页面 2024-12-11 17:37:55 +08:00
5d791ce996 修复键盘事件注释中的字符错误,并为虚拟列表组件的样式添加无轮廓属性以提升可用性 2024-12-11 17:20:17 +08:00
9ccba5ea1c 调整公共样式 2024-12-11 17:14:45 +08:00
f5af8ed640 增加scss依赖方便普通代码使用 2024-12-11 17:12:34 +08:00
f0ec23bcce 优化变量描述 2024-12-11 16:44:22 +08:00
5f08e317fa 增加项目说明 2024-12-11 16:39:32 +08:00
66aca32b65 样式优化 2024-12-11 16:36:21 +08:00
13997ea579 调整列表项内容布局 2024-12-11 16:25:17 +08:00
8d0d4bf1a9 material design样式 2024-12-11 16:07:37 +08:00
d218789c2b 新增虚拟滚动阈值逻辑以优化性能,改进可视化数据计算和滚动处理,确保在数据量较小时直接渲染全部数据。 2024-12-11 15:49:05 +08:00
0a8af1d97b 优化参数配置,简化组件构建 2024-12-11 15:40:44 +08:00
812a642aaa 改为typescript支持 2024-12-11 15:32:20 +08:00
72e3d1b756 增加标签栏显示 2024-12-11 15:07:46 +08:00
d90aa86f3f 优化事件处理,使外部逻辑更简单 2024-12-11 14:57:05 +08:00
89740926c5 主界面列表组件化 2024-12-11 14:48:59 +08:00
68da52bbb5 添加 Toast 通知功能,优化鼠标悬浮和键盘导航逻辑,增强用户交互体验。临时禁用鼠标悬浮功能以避免误操作,并在列表边界触发时提供反馈。 2024-12-11 14:44:18 +08:00
21 changed files with 2312 additions and 598 deletions

View File

@@ -1,5 +1,3 @@
# Vue 3 + Vite # uTools虚拟列表实现
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. 模版插件的自定义实现,用于扩展模版插件的可能性
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@@ -4,18 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>utools-list</title> <title>utools-list</title>
<style>
* {
margin: 0;
padding: 0;
}
body {
height: 100%;
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -9,10 +9,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.1",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"sass-embedded": "^1.82.0",
"typescript": "^5.0.2",
"vite": "^6.0.1" "vite": "^6.0.1"
} }
} }

498
pnpm-lock.yaml generated
View File

@@ -10,361 +10,549 @@ importers:
dependencies: dependencies:
vue: vue:
specifier: ^3.5.13 specifier: ^3.5.13
version: 3.5.13 version: 3.5.13(typescript@5.7.2)
vue-router:
specifier: ^4.5.0
version: 4.5.0(vue@3.5.13(typescript@5.7.2))
devDependencies: devDependencies:
'@types/node':
specifier: ^22.10.1
version: 22.10.1
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1(vite@6.0.3)(vue@3.5.13) version: 5.2.1(vite@6.0.3(@types/node@22.10.1)(sass-embedded@1.82.0))(vue@3.5.13(typescript@5.7.2))
sass-embedded:
specifier: ^1.82.0
version: 1.82.0
typescript:
specifier: ^5.0.2
version: 5.7.2
vite: vite:
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.3 version: 6.0.3(@types/node@22.10.1)(sass-embedded@1.82.0)
packages: packages:
'@babel/helper-string-parser@7.25.9': '@babel/helper-string-parser@7.25.9':
resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz} resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.25.9': '@babel/helper-validator-identifier@7.25.9':
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz} resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/parser@7.26.3': '@babel/parser@7.26.3':
resolution: {integrity: sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz} resolution: {integrity: sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/types@7.26.3': '@babel/types@7.26.3':
resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz} resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@bufbuild/protobuf@2.2.3':
resolution: {integrity: sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==}
'@esbuild/aix-ppc64@0.24.0': '@esbuild/aix-ppc64@0.24.0':
resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz} resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [ppc64] cpu: [ppc64]
os: [aix] os: [aix]
'@esbuild/android-arm64@0.24.0': '@esbuild/android-arm64@0.24.0':
resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz} resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@esbuild/android-arm@0.24.0': '@esbuild/android-arm@0.24.0':
resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz} resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@esbuild/android-x64@0.24.0': '@esbuild/android-x64@0.24.0':
resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz} resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [android] os: [android]
'@esbuild/darwin-arm64@0.24.0': '@esbuild/darwin-arm64@0.24.0':
resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz} resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@esbuild/darwin-x64@0.24.0': '@esbuild/darwin-x64@0.24.0':
resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz} resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@esbuild/freebsd-arm64@0.24.0': '@esbuild/freebsd-arm64@0.24.0':
resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz} resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@esbuild/freebsd-x64@0.24.0': '@esbuild/freebsd-x64@0.24.0':
resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz} resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@esbuild/linux-arm64@0.24.0': '@esbuild/linux-arm64@0.24.0':
resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz} resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@esbuild/linux-arm@0.24.0': '@esbuild/linux-arm@0.24.0':
resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz} resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@esbuild/linux-ia32@0.24.0': '@esbuild/linux-ia32@0.24.0':
resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz} resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [ia32] cpu: [ia32]
os: [linux] os: [linux]
'@esbuild/linux-loong64@0.24.0': '@esbuild/linux-loong64@0.24.0':
resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz} resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@esbuild/linux-mips64el@0.24.0': '@esbuild/linux-mips64el@0.24.0':
resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz} resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [mips64el] cpu: [mips64el]
os: [linux] os: [linux]
'@esbuild/linux-ppc64@0.24.0': '@esbuild/linux-ppc64@0.24.0':
resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz} resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@esbuild/linux-riscv64@0.24.0': '@esbuild/linux-riscv64@0.24.0':
resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz} resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@esbuild/linux-s390x@0.24.0': '@esbuild/linux-s390x@0.24.0':
resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz} resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@esbuild/linux-x64@0.24.0': '@esbuild/linux-x64@0.24.0':
resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz} resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@esbuild/netbsd-x64@0.24.0': '@esbuild/netbsd-x64@0.24.0':
resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz} resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [netbsd] os: [netbsd]
'@esbuild/openbsd-arm64@0.24.0': '@esbuild/openbsd-arm64@0.24.0':
resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz} resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm64] cpu: [arm64]
os: [openbsd] os: [openbsd]
'@esbuild/openbsd-x64@0.24.0': '@esbuild/openbsd-x64@0.24.0':
resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz} resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [openbsd] os: [openbsd]
'@esbuild/sunos-x64@0.24.0': '@esbuild/sunos-x64@0.24.0':
resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz} resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [sunos] os: [sunos]
'@esbuild/win32-arm64@0.24.0': '@esbuild/win32-arm64@0.24.0':
resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz} resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@esbuild/win32-ia32@0.24.0': '@esbuild/win32-ia32@0.24.0':
resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz} resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@esbuild/win32-x64@0.24.0': '@esbuild/win32-x64@0.24.0':
resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz} resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==}
engines: {node: '>=18'} engines: {node: '>=18'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@jridgewell/sourcemap-codec@1.5.0': '@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==, tarball: https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz} resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@rollup/rollup-android-arm-eabi@4.28.1': '@rollup/rollup-android-arm-eabi@4.28.1':
resolution: {integrity: sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz} resolution: {integrity: sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==}
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@rollup/rollup-android-arm64@4.28.1': '@rollup/rollup-android-arm64@4.28.1':
resolution: {integrity: sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz} resolution: {integrity: sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rollup/rollup-darwin-arm64@4.28.1': '@rollup/rollup-darwin-arm64@4.28.1':
resolution: {integrity: sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz} resolution: {integrity: sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rollup/rollup-darwin-x64@4.28.1': '@rollup/rollup-darwin-x64@4.28.1':
resolution: {integrity: sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz} resolution: {integrity: sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rollup/rollup-freebsd-arm64@4.28.1': '@rollup/rollup-freebsd-arm64@4.28.1':
resolution: {integrity: sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz} resolution: {integrity: sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==}
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-freebsd-x64@4.28.1': '@rollup/rollup-freebsd-x64@4.28.1':
resolution: {integrity: sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz} resolution: {integrity: sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.28.1': '@rollup/rollup-linux-arm-gnueabihf@4.28.1':
resolution: {integrity: sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz} resolution: {integrity: sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.28.1': '@rollup/rollup-linux-arm-musleabihf@4.28.1':
resolution: {integrity: sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz} resolution: {integrity: sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.28.1': '@rollup/rollup-linux-arm64-gnu@4.28.1':
resolution: {integrity: sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz} resolution: {integrity: sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-arm64-musl@4.28.1': '@rollup/rollup-linux-arm64-musl@4.28.1':
resolution: {integrity: sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz} resolution: {integrity: sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.28.1': '@rollup/rollup-linux-loongarch64-gnu@4.28.1':
resolution: {integrity: sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz} resolution: {integrity: sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.28.1': '@rollup/rollup-linux-powerpc64le-gnu@4.28.1':
resolution: {integrity: sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz} resolution: {integrity: sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.28.1': '@rollup/rollup-linux-riscv64-gnu@4.28.1':
resolution: {integrity: sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz} resolution: {integrity: sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.28.1': '@rollup/rollup-linux-s390x-gnu@4.28.1':
resolution: {integrity: sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz} resolution: {integrity: sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-gnu@4.28.1': '@rollup/rollup-linux-x64-gnu@4.28.1':
resolution: {integrity: sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz} resolution: {integrity: sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-linux-x64-musl@4.28.1': '@rollup/rollup-linux-x64-musl@4.28.1':
resolution: {integrity: sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz} resolution: {integrity: sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.28.1': '@rollup/rollup-win32-arm64-msvc@4.28.1':
resolution: {integrity: sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz} resolution: {integrity: sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.28.1': '@rollup/rollup-win32-ia32-msvc@4.28.1':
resolution: {integrity: sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz} resolution: {integrity: sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@rollup/rollup-win32-x64-msvc@4.28.1': '@rollup/rollup-win32-x64-msvc@4.28.1':
resolution: {integrity: sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz} resolution: {integrity: sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@types/estree@1.0.6': '@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz} resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
'@types/node@22.10.1':
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
'@vitejs/plugin-vue@5.2.1': '@vitejs/plugin-vue@5.2.1':
resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==, tarball: https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz} resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies: peerDependencies:
vite: ^5.0.0 || ^6.0.0 vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vue/compiler-core@3.5.13': '@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==, tarball: https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz} resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
'@vue/compiler-dom@3.5.13': '@vue/compiler-dom@3.5.13':
resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==, tarball: https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz} resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==}
'@vue/compiler-sfc@3.5.13': '@vue/compiler-sfc@3.5.13':
resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==, tarball: https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz} resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==}
'@vue/compiler-ssr@3.5.13': '@vue/compiler-ssr@3.5.13':
resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==, tarball: https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz} resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/reactivity@3.5.13': '@vue/reactivity@3.5.13':
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==, tarball: https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz} resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
'@vue/runtime-core@3.5.13': '@vue/runtime-core@3.5.13':
resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==, tarball: https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz} resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==}
'@vue/runtime-dom@3.5.13': '@vue/runtime-dom@3.5.13':
resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==, tarball: https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz} resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==}
'@vue/server-renderer@3.5.13': '@vue/server-renderer@3.5.13':
resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==, tarball: https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz} resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==}
peerDependencies: peerDependencies:
vue: 3.5.13 vue: 3.5.13
'@vue/shared@3.5.13': '@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==, tarball: https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz} resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
buffer-builder@0.2.0:
resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==}
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, tarball: https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
entities@4.5.0: entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, tarball: https://registry.npmjs.org/entities/-/entities-4.5.0.tgz} resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
esbuild@0.24.0: esbuild@0.24.0:
resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz} resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==, tarball: https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
immutable@5.0.3:
resolution: {integrity: sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==}
magic-string@0.30.15: magic-string@0.30.15:
resolution: {integrity: sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==, tarball: https://registry.npmjs.org/magic-string/-/magic-string-0.30.15.tgz} resolution: {integrity: sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==}
nanoid@3.3.8: nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz} resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.4.49: postcss@8.4.49:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz} resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
rollup@4.28.1: rollup@4.28.1:
resolution: {integrity: sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz} resolution: {integrity: sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
sass-embedded-android-arm64@1.82.0:
resolution: {integrity: sha512-bldHMs02QQWXsgHUZRgolNnZdMjN6XHvmUYoRkzmFq7lsvtLU6SJg2S1Wa9IZJs9jRWdTmOgA6YibSf3pROyFQ==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [android]
sass-embedded-android-arm@1.82.0:
resolution: {integrity: sha512-ttGMvWnA/5TYdZTjr5fWHDbb9nZgKipHKCc9zZQRF5HjUydOYWKNqmAJHQtbFWaq35kd5qn6yiE73IJN6eJ6wA==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [android]
sass-embedded-android-ia32@1.82.0:
resolution: {integrity: sha512-FUJOnxw8IYKuYuxxiOkk6QXle8/yQFtKjnuSAJuZ5ZpLVMcSZzLc3SWOtuEXYx5iSAfJCO075o2ZoG/pPrJ9aw==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [android]
sass-embedded-android-riscv64@1.82.0:
resolution: {integrity: sha512-rd+vc+sxJxNnbhaubiIJmnb1b3FvC9wxCIq8spstopbO7o1uufvBBDeRoFSJaN+7oNhamzjlYGdu6aQoQNs3+A==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [android]
sass-embedded-android-x64@1.82.0:
resolution: {integrity: sha512-EVlybGTgJ8wNLyWj8RUatPXSnmIcvCsx3EfsRfBfhGihLbn4NNpavYO9QsvZzI2XWbJqHLBCd+CvkTcDw/TaSQ==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [android]
sass-embedded-darwin-arm64@1.82.0:
resolution: {integrity: sha512-LvdJPojjKlNGYOB0nSUR/ZtMDuAF4puspHlwK42aA/qK292bfSkMUKZPPapB2aSRwccc/ieBq5fI7n/WHrOCVw==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [darwin]
sass-embedded-darwin-x64@1.82.0:
resolution: {integrity: sha512-6LfnD6YmG1aBfd3ReqMOJDb6Pg2Z/hmlJB7nU+Lb3E+hCNjAZAgeUHQxU/Pm1eIqJJTU/h4ib5QP0Pt9O8yVnw==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [darwin]
sass-embedded-linux-arm64@1.82.0:
resolution: {integrity: sha512-590/y0HJr/JiyxaqgR7Xf9P20BIhJ+zhB/afAnVuZe/4lEfCpTyM5xMe2+sKLsqtrVyzs9Zm/M4S4ASUOPCggA==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-arm@1.82.0:
resolution: {integrity: sha512-ozjdC5rWzyi5Vo300I4tVZzneXOTQUiaxOr7DjtN26HuFaGAGCGmvThh2BRV4RvySg++5H9rdFu+VgyUQ5iukw==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-ia32@1.82.0:
resolution: {integrity: sha512-hpc4acZ3UTjjJ3Q/GUXqQOCSml6AFKaku0HMawra9bKyRmOpxn8V5hqgXeOWVjK2oQzCmCnJvwKoQUP+S/SIYQ==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-arm64@1.82.0:
resolution: {integrity: sha512-bc2MUSMv/jabnNGEyKP2jQAYZoEzTT/c633W6QoeSEWETGCuTNjaHvWWE6qSI6/UfRg1EpuV1LQA2jPMzZfv/w==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-musl-arm@1.82.0:
resolution: {integrity: sha512-R5PQmY/I+GSoMtfLo8GgHkvF/q6x6y8VNM7yu/Ac1mJj86n48VFi29W1HfY2496+Q6cpAq7toobDj7YfldIdVA==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-ia32@1.82.0:
resolution: {integrity: sha512-ZQKCFKm5TBcJ19UG6uUQmIKfVCJIWMb7e1a93lGeujSb9gyKF5Fb6MN3tuExoT7iFK8zU0Z9iyHqh93F58lcCw==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-riscv64@1.82.0:
resolution: {integrity: sha512-5meSU8BHFeaT09RWfkuUrikRlC+WZcYb9To7MpfV1d9nlD7CZ2xydPExK+mj3DqRuQvTbvhMPcr7f+pHlgHINQ==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-musl-x64@1.82.0:
resolution: {integrity: sha512-ASLAMfjWv7YEPBvEOVlb3zzHq8l4Y9Eh4x3m7B1dNauGVbO11Yng5cPCX/XbwGVf30BtE75pwqvV7oXxBtN15w==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-linux-riscv64@1.82.0:
resolution: {integrity: sha512-qWvRDXCXH3GzD8OcP0ntd8gBTK3kZyUeyXmxQDZyEtMAM4STC2Tn7+5+2JYYHlppzqWnZPFBNESvpKeOtHaBBw==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-x64@1.82.0:
resolution: {integrity: sha512-AmRaHqShztwfep+M4NagdGaY7fTyWGSOM3k4Z/dd7q4nZclXbALLqNJtKx8xOM7A41LHYJ9zDpIBVRkrh0PzTA==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-win32-arm64@1.82.0:
resolution: {integrity: sha512-zL9JDQZHXHSGAZe5DqSrR86wMHbm9QPziU4/3hoIG+99StuS74CuV42+hw/+FXXBkXMWbjKWsyF/HZt+I/wJuw==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [win32]
sass-embedded-win32-ia32@1.82.0:
resolution: {integrity: sha512-xE+AzLquCkFPnnpo0NHjQdLRIhG1bVs42xIKx42aUbVLYKkBDvbBGpw6EtTscRMyvcjoOqGH5saRvSFComUQcw==}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [win32]
sass-embedded-win32-x64@1.82.0:
resolution: {integrity: sha512-cEgfOQG5womOzzk16ReTv2dxPq5BG16LgLUold/LH9IZH86u4E/MN7Fspf4RWeEJ2EcLdew9QYSC2YWs1l98dQ==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [win32]
sass-embedded@1.82.0:
resolution: {integrity: sha512-v13sRVVZtWAQLpAGTz5D8hy+oyNKRHao5tKVc/P6AMqSP+jDM8X6GkEpL0jfbu3MaN2/hAQsd4Qx14GG1u0prQ==}
engines: {node: '>=16.0.0'}
hasBin: true
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
sync-child-process@1.0.2:
resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
engines: {node: '>=16.0.0'}
sync-message-port@1.1.3:
resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==}
engines: {node: '>=16.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.7.2:
resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
vite@6.0.3: vite@6.0.3:
resolution: {integrity: sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw==, tarball: https://registry.npmjs.org/vite/-/vite-6.0.3.tgz} resolution: {integrity: sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -403,8 +591,13 @@ packages:
yaml: yaml:
optional: true optional: true
vue-router@4.5.0:
resolution: {integrity: sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==}
peerDependencies:
vue: ^3.2.0
vue@3.5.13: vue@3.5.13:
resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==, tarball: https://registry.npmjs.org/vue/-/vue-3.5.13.tgz} resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
peerDependenciesMeta: peerDependenciesMeta:
@@ -426,6 +619,8 @@ snapshots:
'@babel/helper-string-parser': 7.25.9 '@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9 '@babel/helper-validator-identifier': 7.25.9
'@bufbuild/protobuf@2.2.3': {}
'@esbuild/aix-ppc64@0.24.0': '@esbuild/aix-ppc64@0.24.0':
optional: true optional: true
@@ -559,10 +754,14 @@ snapshots:
'@types/estree@1.0.6': {} '@types/estree@1.0.6': {}
'@vitejs/plugin-vue@5.2.1(vite@6.0.3)(vue@3.5.13)': '@types/node@22.10.1':
dependencies: dependencies:
vite: 6.0.3 undici-types: 6.20.0
vue: 3.5.13
'@vitejs/plugin-vue@5.2.1(vite@6.0.3(@types/node@22.10.1)(sass-embedded@1.82.0))(vue@3.5.13(typescript@5.7.2))':
dependencies:
vite: 6.0.3(@types/node@22.10.1)(sass-embedded@1.82.0)
vue: 3.5.13(typescript@5.7.2)
'@vue/compiler-core@3.5.13': '@vue/compiler-core@3.5.13':
dependencies: dependencies:
@@ -594,6 +793,8 @@ snapshots:
'@vue/compiler-dom': 3.5.13 '@vue/compiler-dom': 3.5.13
'@vue/shared': 3.5.13 '@vue/shared': 3.5.13
'@vue/devtools-api@6.6.4': {}
'@vue/reactivity@3.5.13': '@vue/reactivity@3.5.13':
dependencies: dependencies:
'@vue/shared': 3.5.13 '@vue/shared': 3.5.13
@@ -610,14 +811,18 @@ snapshots:
'@vue/shared': 3.5.13 '@vue/shared': 3.5.13
csstype: 3.1.3 csstype: 3.1.3
'@vue/server-renderer@3.5.13(vue@3.5.13)': '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.7.2))':
dependencies: dependencies:
'@vue/compiler-ssr': 3.5.13 '@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13 '@vue/shared': 3.5.13
vue: 3.5.13 vue: 3.5.13(typescript@5.7.2)
'@vue/shared@3.5.13': {} '@vue/shared@3.5.13': {}
buffer-builder@0.2.0: {}
colorjs.io@0.5.2: {}
csstype@3.1.3: {} csstype@3.1.3: {}
entities@4.5.0: {} entities@4.5.0: {}
@@ -654,6 +859,10 @@ snapshots:
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
has-flag@4.0.0: {}
immutable@5.0.3: {}
magic-string@0.30.15: magic-string@0.30.15:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
@@ -693,20 +902,143 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.28.1 '@rollup/rollup-win32-x64-msvc': 4.28.1
fsevents: 2.3.3 fsevents: 2.3.3
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
sass-embedded-android-arm64@1.82.0:
optional: true
sass-embedded-android-arm@1.82.0:
optional: true
sass-embedded-android-ia32@1.82.0:
optional: true
sass-embedded-android-riscv64@1.82.0:
optional: true
sass-embedded-android-x64@1.82.0:
optional: true
sass-embedded-darwin-arm64@1.82.0:
optional: true
sass-embedded-darwin-x64@1.82.0:
optional: true
sass-embedded-linux-arm64@1.82.0:
optional: true
sass-embedded-linux-arm@1.82.0:
optional: true
sass-embedded-linux-ia32@1.82.0:
optional: true
sass-embedded-linux-musl-arm64@1.82.0:
optional: true
sass-embedded-linux-musl-arm@1.82.0:
optional: true
sass-embedded-linux-musl-ia32@1.82.0:
optional: true
sass-embedded-linux-musl-riscv64@1.82.0:
optional: true
sass-embedded-linux-musl-x64@1.82.0:
optional: true
sass-embedded-linux-riscv64@1.82.0:
optional: true
sass-embedded-linux-x64@1.82.0:
optional: true
sass-embedded-win32-arm64@1.82.0:
optional: true
sass-embedded-win32-ia32@1.82.0:
optional: true
sass-embedded-win32-x64@1.82.0:
optional: true
sass-embedded@1.82.0:
dependencies:
'@bufbuild/protobuf': 2.2.3
buffer-builder: 0.2.0
colorjs.io: 0.5.2
immutable: 5.0.3
rxjs: 7.8.1
supports-color: 8.1.1
sync-child-process: 1.0.2
varint: 6.0.0
optionalDependencies:
sass-embedded-android-arm: 1.82.0
sass-embedded-android-arm64: 1.82.0
sass-embedded-android-ia32: 1.82.0
sass-embedded-android-riscv64: 1.82.0
sass-embedded-android-x64: 1.82.0
sass-embedded-darwin-arm64: 1.82.0
sass-embedded-darwin-x64: 1.82.0
sass-embedded-linux-arm: 1.82.0
sass-embedded-linux-arm64: 1.82.0
sass-embedded-linux-ia32: 1.82.0
sass-embedded-linux-musl-arm: 1.82.0
sass-embedded-linux-musl-arm64: 1.82.0
sass-embedded-linux-musl-ia32: 1.82.0
sass-embedded-linux-musl-riscv64: 1.82.0
sass-embedded-linux-musl-x64: 1.82.0
sass-embedded-linux-riscv64: 1.82.0
sass-embedded-linux-x64: 1.82.0
sass-embedded-win32-arm64: 1.82.0
sass-embedded-win32-ia32: 1.82.0
sass-embedded-win32-x64: 1.82.0
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
vite@6.0.3: supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
sync-child-process@1.0.2:
dependencies:
sync-message-port: 1.1.3
sync-message-port@1.1.3: {}
tslib@2.8.1: {}
typescript@5.7.2: {}
undici-types@6.20.0: {}
varint@6.0.0: {}
vite@6.0.3(@types/node@22.10.1)(sass-embedded@1.82.0):
dependencies: dependencies:
esbuild: 0.24.0 esbuild: 0.24.0
postcss: 8.4.49 postcss: 8.4.49
rollup: 4.28.1 rollup: 4.28.1
optionalDependencies: optionalDependencies:
'@types/node': 22.10.1
fsevents: 2.3.3 fsevents: 2.3.3
sass-embedded: 1.82.0
vue@3.5.13: vue-router@4.5.0(vue@3.5.13(typescript@5.7.2)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.13(typescript@5.7.2)
vue@3.5.13(typescript@5.7.2):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.13 '@vue/compiler-dom': 3.5.13
'@vue/compiler-sfc': 3.5.13 '@vue/compiler-sfc': 3.5.13
'@vue/runtime-dom': 3.5.13 '@vue/runtime-dom': 3.5.13
'@vue/server-renderer': 3.5.13(vue@3.5.13) '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.7.2))
'@vue/shared': 3.5.13 '@vue/shared': 3.5.13
optionalDependencies:
typescript: 5.7.2

View File

@@ -1,368 +1,10 @@
<script setup> <script setup lang="ts">
import VirtualList from './components/VirtualList.vue' import { useRouter } from 'vue-router'
import { ref, onMounted, onBeforeUnmount } from 'vue'
// 虚拟列表配置 const router = useRouter()
const listConfig = { router.push('/config')
itemHeight: 50,
itemPadding: 16,
bufferCount: 10,
scrollDebounceTime: 16,
}
// 生成模拟数据
const listData = Array.from({ length: 100 }, (_, i) => ({
id: i,
name: `project-${i + 1}`,
path: `/Users/lanyuanxiaoyao/Project/IdeaProjects/project-${i + 1}`,
}))
// 添加当前选中项的状态
const selectedItem = ref(null)
// 修改选择处理函数
const handleSelect = (item) => {
selectedItem.value = item
}
// 添加点击处理函数
const handleClick = (item) => {
console.log('Clicked:', item)
// 这里可以添加点击特定处理逻辑
}
// 菜单配置
const menuItems = [
{ id: 'refresh', label: '刷新列表' },
{ id: 'clear', label: '清空选择' },
{ id: 'export', label: '导出数据' },
]
// 控制菜单显示状态
const showMenu = ref(false)
// 添加列表冻结状态
const listFrozen = ref(false)
// 修改菜单显示状态的处理
const showContextMenu = (data) => {
const { item } = data
// 选中该项
handleSelect(item)
// 显示菜单
showMenu.value = true
listFrozen.value = true // 冻结列表
}
// 修改关闭菜单的处理
const closeMenu = () => {
showMenu.value = false
listFrozen.value = false
selectedMenuIndex.value = -1 // 重置菜单选中项
}
// 添加当前选中的菜单项索引
const selectedMenuIndex = ref(-1)
// 修改键盘事件处理函数
const handleKeyDown = (e) => {
if (e.key === 'ArrowRight' && !showMenu.value && selectedItem.value) {
e.preventDefault()
handleMenuTriggerClick(e)
} else if (e.key === 'ArrowLeft' && showMenu.value) {
e.preventDefault()
showMenu.value = false
listFrozen.value = false
selectedMenuIndex.value = -1 // 重置菜单选中项
} else if (e.key === 'Escape') {
// ESC 键只在菜单打开时响应
if (showMenu.value) {
e.preventDefault()
closeMenu()
}
} else if (showMenu.value) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
selectedMenuIndex.value =
selectedMenuIndex.value <= 0
? menuItems.length - 1
: selectedMenuIndex.value - 1
break
case 'ArrowDown':
e.preventDefault()
selectedMenuIndex.value =
selectedMenuIndex.value >= menuItems.length - 1
? 0
: selectedMenuIndex.value + 1
break
case 'Enter':
e.preventDefault()
if (selectedMenuIndex.value >= 0) {
handleMenuClick(menuItems[selectedMenuIndex.value])
selectedMenuIndex.value = -1 // 重置菜单选中项
}
break
}
}
}
onMounted(() => {
// 添加全局点击事件来关闭菜单
document.addEventListener('click', closeMenu)
// 添加键盘事件监听
document.addEventListener('keydown', handleKeyDown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu)
// 移除键盘事件监听
document.removeEventListener('keydown', handleKeyDown)
})
// 修改菜单击处理
const handleMenuClick = (item) => {
console.log('Menu clicked:', item)
showMenu.value = false
listFrozen.value = false // 解冻列表
}
// 修改菜单触发器点击处理
const handleMenuTriggerClick = (e) => {
e.stopPropagation()
// 切换菜单显示状态
if (showMenu.value) {
showMenu.value = false
listFrozen.value = false // 解冻列表
} else {
// 如果有选中项,选中第一个可见项
if (listData.length > 0) {
// 如果已有选中项就保持当前选中,否则选中第一项
const itemToSelect = selectedItem.value || listData[0]
showContextMenu({ item: itemToSelect })
} else {
showMenu.value = true
listFrozen.value = true // 冻结列表
}
}
}
// 添加过渡钩子函数
const onBeforeLeave = (el) => {
// 确保元素在离开过渡开始时可见
el.style.display = 'block'
}
const onAfterLeave = (el) => {
// 过渡结束后重置样式
el.style.display = ''
}
</script> </script>
<template> <template>
<div class="app-container" @contextmenu.prevent> <router-view />
<div class="main-content">
<VirtualList
:data="listData"
:config="listConfig"
:frozen="listFrozen"
@select="handleSelect"
@click="handleClick"
@contextmenu="showContextMenu"
/>
</div>
<div class="toolbar">
<div class="toolbar-content">
<div class="total-count"> {{ listData.length }} </div>
<div class="toolbar-spacer"></div>
<div
class="menu-trigger"
:class="{ active: showMenu }"
@click="handleMenuTriggerClick"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="6" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="18" r="2" fill="currentColor" />
</svg>
<!-- 使用 transition 组件包裹菜单 -->
<transition
name="menu"
@before-leave="onBeforeLeave"
@after-leave="onAfterLeave"
>
<div v-if="showMenu" class="popup-menu" @click.stop>
<div v-if="selectedItem" class="selected-item-info">
<div class="selected-item-name">{{ selectedItem.name }}</div>
<div class="selected-item-path">{{ selectedItem.path }}</div>
</div>
<div class="menu-divider"></div>
<div
v-for="(item, index) in menuItems"
:key="item.id"
class="menu-item"
:class="{ 'menu-item-selected': index === selectedMenuIndex }"
@click="handleMenuClick(item)"
>
{{ item.label }}
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</template> </template>
<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
#app {
height: 100%;
}
</style>
<style scoped>
.app-container {
height: 100%;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
min-height: 0; /* 重要:防止内容溢出 */
}
.toolbar {
height: 40px;
border-top: 1px solid #e8e8e8;
background-color: #fff;
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.05);
position: relative;
z-index: 1;
}
.toolbar-content {
height: 100%;
padding: 0 16px;
display: flex;
align-items: center;
}
.total-count {
font-size: 13px;
color: #666;
}
.toolbar-spacer {
flex: 1;
}
.menu-trigger {
position: relative;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #666;
border-radius: 6px;
transition: all 0.2s ease;
}
.menu-trigger:hover {
background-color: #f5f5f5;
color: #1a1a1a;
}
.menu-trigger.active {
background-color: #f5f5f5;
color: #1a1a1a;
}
.popup-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 23px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04);
min-width: 260px;
padding: 8px 0;
z-index: 1000;
border: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
transform-origin: top right;
}
/* 添加 transition 相关样式 */
.menu-enter-active,
.menu-leave-active {
transition: all 0.2s ease-out;
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: scale(0.95);
}
.menu-enter-to,
.menu-leave-from {
opacity: 1;
transform: scale(1);
}
.menu-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.06);
margin: 6px 0;
}
.menu-item {
padding: 10px 16px;
margin: 0 4px;
font-size: 13px;
color: #333;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 8px;
display: flex;
align-items: center;
font-weight: 500;
}
.menu-item:hover, .menu-item-selected {
background-color: rgba(79, 70, 229, 0.06);
color: #4f46e5;
}
.selected-item-info {
padding: 12px 16px;
margin: 0 4px 4px 4px;
border-radius: 8px;
}
.selected-item-name {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 8px;
}
.selected-item-path {
font-size: 12px;
color: #666;
word-break: break-all;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,720 @@
<script setup lang="ts">
import VirtualList from './VirtualList.vue'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import type { ListItem, MenuItem } from '@/types'
import { keyboardManager } from '@/utils/KeyboardManager'
/**
* 组件属性接口
*/
interface Props {
/** 列表数据 */
data: ListItem[]
}
const props = withDefaults(defineProps<Props>(), {})
// 在显示菜单时使用选中项的菜单列表
const currentMenuItems = computed(() => selectedItem.value?.menuItems || [])
// ===== 工具栏配置常量 =====
/** 是否显示工具栏 */
const SHOW_TOOLBAR = true
/** 工具栏高度(像素) */
const TOOLBAR_HEIGHT = 40
/**
* 组件事件定义
*/
const emit = defineEmits<{
/** 选中项变化事件 */
select: [item: ListItem]
/** 点击项目事件 */
click: [item: ListItem]
/** 右键菜单事件 */
'context-menu': [data: { item: ListItem }]
}>()
// ===== 状态管理 =====
/** 当前选中的列表项 */
const selectedItem = ref<ListItem | null>(null)
/** 菜单显示状态 */
const showMenu = ref<boolean>(false)
/** 列表冻结状态 */
const listFrozen = ref<boolean>(false)
/** 临时禁用鼠标悬停状态 */
const temporaryDisableHover = ref<boolean>(false)
/** 当前选中的菜单项索引 */
const selectedMenuIndex = ref<number>(-1)
/** Toast 显示状态 */
const toastVisible = ref<boolean>(false)
/** Toast 消息内容 */
const toastMessage = ref<string>('')
/** 是否正在使用键盘导航菜单 */
const isKeyboardNavigation = ref<boolean>(false)
/** 快捷键处理函数映射 */
const shortcutHandlers = new Map<string, (e: KeyboardEvent) => void>()
// ===== 定时器 =====
/** 鼠标悬停禁用定时器 */
let hoverDisableTimer: number | null = null
/** Toast 显示定时器 */
let toastTimer: number | null = null
// ===== 事件处理函数 =====
/**
* 处理列表项选中
* @param item 选中的列表项
*/
const handleSelect = (item: ListItem): void => {
selectedItem.value = item
emit('select', item)
}
/**
* 处理列表项点击
* @param item 点击的列表项
*/
const handleClick = (item: ListItem): void => {
emit('click', item)
}
/**
* 显示右键菜单
* @param data 包含目标列表项的数据对象
*/
const showContextMenu = (data: { item: ListItem }): void => {
const { item } = data
handleSelect(item)
showMenu.value = true
listFrozen.value = true
selectedMenuIndex.value = 0
isKeyboardNavigation.value = true
bindShortcuts() // 打开菜单时绑定快捷键
emit('context-menu', data)
}
/**
* 关闭菜单处理相关状态
*/
const closeMenu = (): void => {
showMenu.value = false
listFrozen.value = false
selectedMenuIndex.value = -1
isKeyboardNavigation.value = false
// 清除快捷键绑定
keyboardManager.disable()
shortcutHandlers.clear()
// 临时禁用鼠标悬停防止菜单关闭时<E997AD><E697B6><EFBFBD>即触发悬停效果
temporaryDisableHover.value = true
if (hoverDisableTimer) clearTimeout(hoverDisableTimer)
hoverDisableTimer = window.setTimeout(() => {
temporaryDisableHover.value = false
}, 300)
}
/**
* 处理菜单项点击
* @param item 被点击的菜单项
*/
const handleMenuClick = (item: MenuItem): void => {
item.action(selectedItem.value)
closeMenu()
}
/**
* 处理菜单触发器点击
* 控制菜单的显示/隐藏状态
* @param e 鼠标事件对象
*/
const handleMenuTriggerClick = (e: MouseEvent): void => {
e.stopPropagation()
if (showMenu.value) {
closeMenu()
} else {
if (props.data.length > 0) {
// 如果有数据,选择当前选中项或第一项
const itemToSelect = selectedItem.value || props.data[0]
showContextMenu({ item: itemToSelect })
} else {
// 如果没有数据,仅显示菜单
showMenu.value = true
listFrozen.value = true
selectedMenuIndex.value = 0
isKeyboardNavigation.value = true
bindShortcuts() // 打开菜单时绑定快捷键
}
}
}
/**
* 处理键盘事件
* - 右方向键:打开菜单
* - 左方向键/ESC关闭菜单
* - 上下方向键:在菜单打开时导航菜单项
* - 回车键:在菜单打开时选择当前菜单项
* @param e 键盘事件<E4BA8B><E4BBB6>
*/
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'ArrowRight' && !showMenu.value && selectedItem.value) {
e.preventDefault()
handleMenuTriggerClick(new MouseEvent('click'))
} else if ((e.key === 'ArrowLeft' || e.key === 'Escape') && showMenu.value) {
e.preventDefault()
closeMenu()
} else if (showMenu.value) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
isKeyboardNavigation.value = true
// 向上选择菜单项,到顶部时循环到底部
selectedMenuIndex.value =
selectedMenuIndex.value <= 0
? currentMenuItems.value.length - 1
: selectedMenuIndex.value - 1
break
case 'ArrowDown':
e.preventDefault()
isKeyboardNavigation.value = true
// 向下选择菜单项,到底部时循环到顶部
selectedMenuIndex.value =
selectedMenuIndex.value >= currentMenuItems.value.length - 1
? 0
: selectedMenuIndex.value + 1
break
case 'Enter':
e.preventDefault()
if (selectedMenuIndex.value >= 0) {
handleMenuClick(currentMenuItems.value[selectedMenuIndex.value])
}
break
}
}
}
/**
* 显示 Toast 消息
* @param message 要显示的消息内容
*/
const showToast = (message: string): void => {
if (toastTimer) clearTimeout(toastTimer)
toastMessage.value = message
toastVisible.value = true
toastTimer = window.setTimeout(() => {
toastVisible.value = false
}, 3000)
}
// 添加鼠标悬浮处理函数
const handleMenuItemHover = (index: number): void => {
selectedMenuIndex.value = index
}
// ===== 生命周期钩子 =====
/**
* 组件挂载时添加全局事件监听
*/
onMounted(() => {
document.addEventListener('click', closeMenu)
document.addEventListener('keydown', handleKeyDown)
})
/**
* 组件卸载前清理事件监听和定时器
*/
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu)
document.removeEventListener('keydown', handleKeyDown)
if (toastTimer) clearTimeout(toastTimer)
if (hoverDisableTimer) clearTimeout(hoverDisableTimer)
keyboardManager.disable()
})
// ===== 过渡钩子 =====
/**
* 过渡开始前的处理
* 确保元素在离开过渡开始时可见
* @param el 过渡元素
*/
const onBeforeLeave = (el: Element): void => {
(el as HTMLElement).style.display = 'block'
}
/**
* 过渡结束后的处理
* 重置元素的显示状态
* @param el 过渡元素
*/
const onAfterLeave = (el: Element): void => {
(el as HTMLElement).style.display = ''
}
// 修改 bindShortcuts 函数
const bindShortcuts = (): void => {
// 清除之前的绑定
shortcutHandlers.forEach((_, shortcut) => {
keyboardManager.unbind(shortcut)
})
shortcutHandlers.clear()
// 只在菜单打开时绑定快捷键
if (showMenu.value) {
keyboardManager.enable()
currentMenuItems.value.forEach((item) => {
if (item.shortcut) {
const handler = (e: KeyboardEvent) => {
if (selectedItem.value || !item.action.toString().includes('selectedItem')) {
item.action(selectedItem.value)
closeMenu()
}
}
shortcutHandlers.set(item.shortcut, handler)
keyboardManager.bind(item.shortcut, handler)
}
})
} else {
keyboardManager.disable()
}
}
</script>
<template>
<!-- 主容器阻止默认右键菜单 -->
<div class="project-list-container" @contextmenu.prevent>
<!-- 主要内容区域 -->
<div class="main-content">
<template v-if="data.length > 0">
<VirtualList
:data="data"
:frozen="listFrozen"
:disable-hover="showMenu || temporaryDisableHover"
@select="handleSelect"
@click="handleClick"
@contextmenu="showContextMenu"
@showToast="showToast"
/>
</template>
<div v-else class="empty-state">
<div class="empty-state-content">
<svg class="empty-state-icon" viewBox="0 0 24 24" width="48" height="48">
<path
fill="currentColor"
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"
/>
<path
fill="currentColor"
d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm2 7h-4v-2h4v2z"
opacity=".3"
/>
</svg>
<h3 class="empty-state-title">暂无项目</h3>
<p class="empty-state-description">
当前列表为空您可以添加新的项目或导入现有项目
</p>
</div>
</div>
</div>
<!-- 工具栏 -->
<div v-if="SHOW_TOOLBAR" class="toolbar" :style="{ height: `${TOOLBAR_HEIGHT}px` }">
<div class="toolbar-content">
<div class="total-count"> {{ data.length }} </div>
<div class="toolbar-spacer"></div>
<div
class="menu-trigger"
:class="{ active: showMenu }"
@click="handleMenuTriggerClick"
>
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="6" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="18" r="2" fill="currentColor" />
</svg>
<transition
name="menu"
@before-leave="onBeforeLeave"
@after-leave="onAfterLeave"
>
<div v-if="showMenu" class="popup-menu" @click.stop>
<div v-if="selectedItem" class="selected-item-info">
<div class="selected-item-header">
<div class="selected-item-icon">
<img
v-if="selectedItem.icon"
:src="selectedItem.icon"
:alt="selectedItem.name"
width="24"
height="24"
>
<svg v-else viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="rgba(0, 0, 0, 0.38)" />
</svg>
</div>
<div class="selected-item-name">{{ selectedItem.name }}</div>
</div>
<div class="selected-item-content">
<div class="selected-item-description">{{ selectedItem.description }}</div>
<div class="selected-item-tags" v-if="selectedItem.tags?.length">
<span
v-for="tag in selectedItem.tags"
:key="tag.id"
class="selected-item-tag"
:style="{ backgroundColor: tag.color }"
>
{{ tag.name }}
</span>
</div>
</div>
</div>
<!-- 只在有菜单项时显示分割线和菜单项列表 -->
<template v-if="currentMenuItems.length > 0">
<div class="menu-divider"></div>
<div
v-for="(item, index) in currentMenuItems"
:key="item.id"
class="menu-item"
:class="{ 'menu-item-selected': index === selectedMenuIndex }"
@click="handleMenuClick(item)"
@mouseover="handleMenuItemHover(index)"
>
<span class="menu-item-label">{{ item.label }}</span>
<div v-if="item.shortcut" class="menu-item-shortcut">
<template v-for="(key, keyIndex) in item.shortcut.split('+')" :key="keyIndex">
<span>{{ key }}</span>
<template v-if="keyIndex < item.shortcut.split('+').length - 1">+</template>
</template>
</div>
</div>
</template>
</div>
</transition>
</div>
</div>
</div>
<!-- Toast 消息 -->
<transition name="toast">
<div v-if="toastVisible" class="toast">
{{ toastMessage }}
</div>
</transition>
</div>
</template>
<style scoped>
/* 主容器:使用 flex 布局实现垂直方向的布局结构 */
.project-list-container {
height: 100%;
display: flex;
flex-direction: column;
}
/* 主内容区域:占用剩余空间并防止内容溢出 */
.main-content {
flex: 1;
min-height: 0;
}
/* 工具栏固定高度,顶部边框和阴影效果 */
.toolbar {
height: 40px;
background-color: #fff;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 1;
}
/* 工具栏内容:水平布局,两端对齐 */
.toolbar-content {
height: 100%;
padding: 0 16px;
display: flex;
align-items: center;
}
/* 总数显示:次要文本样式 */
.total-count {
font-size: 13px;
color: rgba(0, 0, 0, 0.6);
}
/* 弹性间隔:用于推开左右两侧的内容 */
.toolbar-spacer {
flex: 1;
}
/* 菜单触发器:圆形按钮样式 */
.menu-trigger {
position: relative;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: rgba(0, 0, 0, 0.54);
border-radius: 6px;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 菜单触发器悬停和激活状态 */
.menu-trigger:hover,
.menu-trigger.active {
background-color: rgba(0, 0, 0, 0.04);
color: rgba(0, 0, 0, 0.87);
}
/* 弹出菜单:浮动面板样式 */
.popup-menu {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 23px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
min-width: 320px;
padding: 8px 0;
z-index: 1000;
transform-origin: top right;
}
/* 菜单动画相关样式 */
.menu-enter-active,
.menu-leave-active {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: scale(0.95);
}
.menu-enter-to,
.menu-leave-from {
opacity: 1;
transform: scale(1);
}
/* 菜单分割线 */
.menu-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.12);
margin: 6px 0;
}
/* 菜单项:交互式列表项样式 */
.menu-item {
padding: 10px 16px;
margin: 0 4px;
font-size: 13px;
color: rgba(0, 0, 0, 0.87);
cursor: pointer;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 400;
}
/* 菜单项标签 */
.menu-item-label {
flex: 1;
min-width: 0;
}
/* 菜单项快捷键 */
.menu-item-shortcut {
margin-left: 24px;
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
display: flex;
gap: 4px;
align-items: center;
}
/* 快捷键按键样式 */
.menu-item-shortcut span {
padding: 2px 6px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;
line-height: 1;
text-transform: capitalize;
min-width: 20px;
text-align: center;
}
/* 快捷键按键按下效果 */
.menu-item-selected .menu-item-shortcut span {
background-color: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.15);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
}
/* 选中项信息区域 */
.selected-item-info {
padding: 12px 16px;
margin: 0 4px 4px 4px;
border-radius: 8px;
}
/* 选中项头部区域 */
.selected-item-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
/* 选中项图标容器 */
.selected-item-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.54);
overflow: hidden;
}
/* 选中项图标图片 */
.selected-item-icon img {
width: 24px;
height: 24px;
object-fit: contain;
}
/* 选中项名称 */
.selected-item-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 选中项内容区域 */
.selected-item-content {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 选中项描述 */
.selected-item-description {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.5;
}
/* 选中项标签容器 */
.selected-item-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* 选中项标签样式 */
.selected-item-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 12px;
font-weight: 400;
white-space: nowrap;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #fff !important;
opacity: 0.9;
}
/* Toast 消息容器:居中显示的浮动提示 */
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #323232;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
pointer-events: none;
max-width: 80%;
text-align: center;
}
/* Toast 动画相关样式 */
.toast-enter-active,
.toast-leave-active {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translate(-50%, calc(-50% - 20px));
}
/* 统一的选中效果 */
.menu-item-selected {
background-color: rgba(0, 0, 0, 0.08);
color: rgba(0, 0, 0, 0.87);
}
/* 空状态容器 */
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fafafa;
border: 1px solid #ccc;
border-radius: 4px;
}
/* 空状态内容区域 */
.empty-state-content {
text-align: center;
padding: 32px;
max-width: 400px;
}
/* 空状态图标 */
.empty-state-icon {
color: rgba(0, 0, 0, 0.38);
margin-bottom: 16px;
}
/* 空状态标题 */
.empty-state-title {
font-size: 20px;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
margin: 0 0 8px;
}
/* 空状态描述文本 */
.empty-state-description {
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
margin: 0;
line-height: 1.5;
}
</style>

View File

@@ -1,69 +1,83 @@
<script setup> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { ListItem } from '@/types'
const props = defineProps({ interface Props {
// 列表数据 data: ListItem[]
data: { height?: string | number
type: Array, frozen?: boolean
required: true disableHover?: boolean
}, }
// 配置参数
config: { const props = withDefaults(defineProps<Props>(), {
type: Object, height: '100%',
default: () => ({ frozen: false,
itemHeight: 50, // 列表项基础高度 disableHover: false
itemPadding: 16, // 列表项上下padding总和
bufferCount: 2, // 上下缓冲区域的项目数量
scrollDebounceTime: 16 // 滚动防抖时间
})
},
// 容器高度
height: {
type: [String, Number],
default: '100%'
},
frozen: {
type: Boolean,
default: false
}
}) })
const emit = defineEmits(['select', 'click', 'contextmenu']) const emit = defineEmits<{
select: [item: ListItem]
click: [item: ListItem]
contextmenu: [data: { item: ListItem }]
showToast: [message: string]
}>()
// ===== 列表配置常量 =====
/** 列表项基础高度(像素) */
const ITEM_HEIGHT = 50
/** 列表项内边距总和(像素) */
const ITEM_PADDING = 16
/** 上下缓冲区域的项目数量 */
const BUFFER_COUNT = 100
/** 滚动防抖时间(毫秒) */
const SCROLL_DEBOUNCE_TIME = 16
/** 虚拟滚动阈值,少于此数量则直接渲染全部 */
const VIRTUALIZATION_THRESHOLD = 500
// 计算列表项实际总高度 // 计算列表项实际总高度
const totalItemHeight = computed(() => const totalItemHeight = computed(() =>
props.config.itemHeight + props.config.itemPadding ITEM_HEIGHT + ITEM_PADDING
) )
/** // 响应式状态管理
* 响应式状态管理 const containerHeight = ref<number>(0)
*/ const startIndex = ref<number>(0)
const containerHeight = ref(0) // 容器高度 const selectedIndex = ref<number>(0)
const startIndex = ref(0) // 可视区域起始索引 const isKeyboardNavigating = ref<boolean>(false)
const selectedIndex = ref(0) // 当前选中项索引 let keyboardTimer: number | null = null
const isKeyboardNavigating = ref(false) // 键盘导航状态
let keyboardTimer = null // 键盘导航定时器
/** // DOM 引用
* DOM 引用 const containerRef = ref<HTMLElement | null>(null)
*/ const listRef = ref<HTMLElement | null>(null)
const containerRef = ref(null)
const listRef = ref(null)
/** // 计算可视区域能显示的列表项数量
* 计算属性
*/
// 计算可区域能显示的列表项数量
const visibleCount = computed(() => const visibleCount = computed(() =>
Math.floor(containerHeight.value / totalItemHeight.value) Math.floor(containerHeight.value / totalItemHeight.value)
) )
/**
* 是否需要虚拟滚动
* 当数据量小于阈值时直接渲染全部
*/
const needVirtualization = computed<boolean>(() =>
props.data.length > VIRTUALIZATION_THRESHOLD
)
// 计算当前需要渲染的数据 // 计算当前需要渲染的数据
const visibleData = computed(() => { const visibleData = computed(() => {
const visibleStart = Math.max(0, startIndex.value - props.config.bufferCount) // 数据量小于阈值时,直接返回全部数据
if (!needVirtualization.value) {
return props.data.map((item, index) => ({
...item,
index
}))
}
// 否则使用虚拟滚动
const visibleStart = Math.max(0, startIndex.value - BUFFER_COUNT)
const visibleEnd = Math.min( const visibleEnd = Math.min(
props.data.length, props.data.length,
startIndex.value + visibleCount.value + props.config.bufferCount startIndex.value + visibleCount.value + BUFFER_COUNT
) )
return props.data.slice(visibleStart, visibleEnd).map((item, index) => ({ return props.data.slice(visibleStart, visibleEnd).map((item, index) => ({
@@ -74,109 +88,192 @@ const visibleData = computed(() => {
// 计算列表偏移量 // 计算列表偏移量
const offsetY = computed(() => const offsetY = computed(() =>
Math.max(0, (startIndex.value - props.config.bufferCount) * totalItemHeight.value) needVirtualization.value
? Math.max(0, (startIndex.value - BUFFER_COUNT) * totalItemHeight.value)
: 0
) )
// 计算虚拟列表总高度 // 计算虚拟列表总高度
const phantomHeight = computed(() => const phantomHeight = computed(() =>
props.data.length * totalItemHeight.value needVirtualization.value
? props.data.length * totalItemHeight.value
: 'auto'
) )
// ... 其他方法保持不变,只需将 CONFIG 替换为 props.config ...
// 选择处理 // 选择处理
const handleSelect = (item) => { const handleSelect = (item: ListItem): void => {
if (props.frozen) return // 如果列表被冻结,不响应选择 if (props.frozen) return
selectedIndex.value = item.index selectedIndex.value = item.index ?? 0
emit('select', item) emit('select', item)
} }
// 点击处理 /**
const handleClick = (item) => { * 处理列表项点击
if (props.frozen) return // 如果列表被冻结,不响应点击 * @param item 点击的列表项
handleSelect(item) // 点击时同时触发选中 */
const handleClick = (item: ListItem): void => {
if (props.frozen) return
handleSelect(item)
emit('click', item) emit('click', item)
} }
// 修改更新容器高度的方法 /**
function updateContainerHeight() { * 更新容器高度
* 从父元素或窗口获取实际高度
*/
function updateContainerHeight(): void {
if (containerRef.value) { if (containerRef.value) {
const parentHeight = containerRef.value.parentElement?.clientHeight const parentHeight = containerRef.value.parentElement?.clientHeight
containerHeight.value = parentHeight || window.innerHeight containerHeight.value = parentHeight || window.innerHeight
} }
} }
// 添加 ResizeObserver 监听容器尺寸变化 /** ResizeObserver 实例,用于监听容器尺寸变化 */
let resizeObserver = null let resizeObserver: ResizeObserver | null = null
// 添加防抖函数 /**
function debounce(fn, delay) { * 防抖函数
let timer = null * 在指定延迟后执行函数,如果在延迟期间再次调用则重置延迟
return function (...args) { * @param fn 需要防抖的函数
* @param delay 延迟时间(毫秒)
*/
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): (...args: Parameters<T>) => void {
let timer: number | null = null
return function (this: any, ...args: Parameters<T>) {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = setTimeout(() => { timer = window.setTimeout(() => {
fn.apply(this, args) fn.apply(this, args)
}, delay) }, delay)
} }
} }
// 滚动处理 /**
* 处理滚动事件(已防抖)
* 根据滚动位置更新可视区的起始索引
*/
const handleScroll = debounce(() => { const handleScroll = debounce(() => {
// 不需要虚拟滚动时直接返回
if (!needVirtualization.value) return
if (!containerRef.value) return if (!containerRef.value) return
const scrollTop = containerRef.value.scrollTop const scrollTop = containerRef.value.scrollTop
// 计算新的起始索引
const newStartIndex = Math.floor(scrollTop / totalItemHeight.value) const newStartIndex = Math.floor(scrollTop / totalItemHeight.value)
// 只有当起始索引发生变化时才更新
if (newStartIndex !== startIndex.value) { if (newStartIndex !== startIndex.value) {
startIndex.value = newStartIndex startIndex.value = newStartIndex
} }
}, props.config.scrollDebounceTime) }, SCROLL_DEBOUNCE_TIME)
// 鼠标移入处理 /** 鼠标悬停禁用状态 */
function handleMouseEnter(index) { const disableHover = ref<boolean>(false)
if (props.frozen) return // 如果列表被冻结,不响应鼠标移入 /** 鼠标悬停禁用定时器 */
let hoverTimer: number | null = null
/**
* 处理鼠标移入事件
* 在非键盘导航状态下更新选中项
* @param index 目标项的索引
*/
function handleMouseEnter(index: number): void {
if (props.frozen || disableHover.value || props.disableHover) return
if (!isKeyboardNavigating.value) { if (!isKeyboardNavigating.value) {
selectedIndex.value = index selectedIndex.value = index
emit('select', { ...props.data[index], index }) emit('select', { ...props.data[index], index })
ensureSelectedItemVisible()
} }
} }
// 键盘事件处理 /**
function handleKeyDown(e) { * 记录到达列表边界的时间
if (props.frozen) return // 如果列表被冻结,不响应键盘事件 * 用于实现边界循环导航的延迟判断
*/
const lastReachBoundaryTime = ref<{ top: number; bottom: number }>({
top: 0,
bottom: 0
})
// 添加新的状态变量用于追踪按键状<E994AE><E78AB6><EFBFBD>
const isKeyPressed = ref<boolean>(false)
let keyPressTimer: number | null = null
/**
* 处理键盘事件
* 实现键盘导航、边界循环和项目选择功能
* @param e 键盘事件对象
*/
function handleKeyDown(e: KeyboardEvent): void {
if (props.frozen) return
if (e.key === 'Enter') { if (e.key === 'Enter') {
// 回车键触发点击事件
if (selectedIndex.value >= 0 && selectedIndex.value < props.data.length) { if (selectedIndex.value >= 0 && selectedIndex.value < props.data.length) {
const item = props.data[selectedIndex.value] const item = props.data[selectedIndex.value]
emit('click', { ...item, index: selectedIndex.value }) emit('click', { ...item, index: selectedIndex.value })
} }
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
const isFirstItem = selectedIndex.value === 0 const isFirstItem = selectedIndex.value === 0
const isLastItem = selectedIndex.value === props.data.length - 1 const isLastItem = selectedIndex.value === props.data.length - 1
const now = Date.now()
// 如果已经在第一项还按上键,或在最后一项还按下键,则不处理 // 只在首尾项时检查按键状态
if ((e.key === 'ArrowUp' && isFirstItem) || if ((e.key === 'ArrowUp' && isFirstItem) || (e.key === 'ArrowDown' && isLastItem)) {
(e.key === 'ArrowDown' && isLastItem)) { if (isKeyPressed.value) {
return
}
isKeyPressed.value = true
if (keyPressTimer) clearTimeout(keyPressTimer)
}
if (e.key === 'ArrowUp' && isFirstItem) {
const timeSinceLastTop = now - lastReachBoundaryTime.value.top
if (timeSinceLastTop < 5000) {
disableHover.value = true
selectedIndex.value = props.data.length - 1
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
ensureSelectedItemVisible()
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = window.setTimeout(() => {
disableHover.value = false
}, 1000)
} else {
lastReachBoundaryTime.value.top = now
emit('showToast', '已经到列表顶端,再次点击将回到列表底端')
}
return return
} }
if (e.key === 'ArrowDown' && isLastItem) {
const timeSinceLastBottom = now - lastReachBoundaryTime.value.bottom
if (timeSinceLastBottom < 5000) {
disableHover.value = true
selectedIndex.value = 0
emit('select', { ...props.data[selectedIndex.value], index: selectedIndex.value })
ensureSelectedItemVisible()
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = window.setTimeout(() => {
disableHover.value = false
}, 1000)
} else {
lastReachBoundaryTime.value.bottom = now
emit('showToast', '已经到列表底端,再次点击将回到列表顶端')
}
return
}
// 非首尾项的正常导航逻辑
isKeyboardNavigating.value = true isKeyboardNavigating.value = true
if (keyboardTimer) clearTimeout(keyboardTimer) if (keyboardTimer) clearTimeout(keyboardTimer)
const containerTop = containerRef.value?.scrollTop || 0
const containerHeight = containerRef.value?.clientHeight || 0
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
const nextIndex = Math.max(0, selectedIndex.value - 1) const nextIndex = Math.max(0, selectedIndex.value - 1)
selectedIndex.value = nextIndex selectedIndex.value = nextIndex
emit('select', { ...props.data[nextIndex], index: nextIndex }) emit('select', { ...props.data[nextIndex], index: nextIndex })
const itemTop = nextIndex * totalItemHeight.value const itemTop = nextIndex * totalItemHeight.value
if (itemTop < containerTop && containerRef.value) { if (itemTop < (containerRef.value?.scrollTop ?? 0) && containerRef.value) {
containerRef.value.scrollTop = itemTop containerRef.value.scrollTop = itemTop
} }
} else { } else {
@@ -185,51 +282,68 @@ function handleKeyDown(e) {
emit('select', { ...props.data[nextIndex], index: nextIndex }) emit('select', { ...props.data[nextIndex], index: nextIndex })
const itemBottom = (nextIndex + 1) * totalItemHeight.value const itemBottom = (nextIndex + 1) * totalItemHeight.value
const scrollBottom = containerTop + containerHeight const scrollBottom = (containerRef.value?.scrollTop ?? 0) + containerHeight.value
if (itemBottom > scrollBottom && containerRef.value) { if (itemBottom > scrollBottom && containerRef.value) {
containerRef.value.scrollTop = itemBottom - containerHeight containerRef.value.scrollTop = itemBottom - containerHeight.value
} }
} }
keyboardTimer = setTimeout(() => { keyboardTimer = window.setTimeout(() => {
isKeyboardNavigating.value = false isKeyboardNavigating.value = false
}, 1000) }, 1000)
} }
} }
// 确保选中项可见 // 添加键盘抬起事件处理函数
function ensureSelectedItemVisible() { function handleKeyUp(e: KeyboardEvent): void {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
// 设置一个短暂的延时,确保不会立即响应下一次按键
if (keyPressTimer) clearTimeout(keyPressTimer)
keyPressTimer = window.setTimeout(() => {
isKeyPressed.value = false
}, 50)
}
}
/**
* 保持选中项在可视区域内
* 如果选中项不可见,则滚动到合适位置
*/
function ensureSelectedItemVisible(): void {
if (!containerRef.value) return if (!containerRef.value) return
const containerTop = containerRef.value.scrollTop const containerTop = containerRef.value.scrollTop
const containerHeight = containerRef.value.clientHeight const containerHeight = containerRef.value.clientHeight
// 计算项目的顶部和底部位置
const itemTop = selectedIndex.value * totalItemHeight.value const itemTop = selectedIndex.value * totalItemHeight.value
const itemBottom = (selectedIndex.value + 1) * totalItemHeight.value const itemBottom = (selectedIndex.value + 1) * totalItemHeight.value
const scrollBottom = containerTop + containerHeight const scrollBottom = containerTop + containerHeight
if (itemTop < containerTop) { if (itemTop < containerTop) {
// 如果项目顶部超出可视区域,滚动到顶部对齐
containerRef.value.scrollTop = itemTop containerRef.value.scrollTop = itemTop
} else if (itemBottom > scrollBottom) { } else if (itemBottom > scrollBottom) {
// 如果项目底部超出可视区域,滚动到底部对齐
containerRef.value.scrollTop = itemBottom - containerHeight containerRef.value.scrollTop = itemBottom - containerHeight
} }
} }
// 修改右键点击处理函数 /**
const handleContextMenu = (e, item) => { * 处理右键点击事件
if (props.frozen) return // 如果列表被冻结,不响应右键点击 * @param e 鼠标事件对象
e.preventDefault() // 阻止默认右键菜单 * @param item 目标列表项
emit('contextmenu', { item }) // 不需要传递事件对象 */
const handleContextMenu = (e: MouseEvent, item: ListItem): void => {
if (props.frozen) return
e.preventDefault()
emit('contextmenu', { item })
} }
// ===== 生命周期钩子 =====
onMounted(() => { onMounted(() => {
if (containerRef.value) { if (containerRef.value) {
// 添加滚动事件监听
containerRef.value.addEventListener('scroll', handleScroll) containerRef.value.addEventListener('scroll', handleScroll)
// 创建 ResizeObserver 监听父容器尺寸变化 // 创建并启用 ResizeObserver
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
updateContainerHeight() updateContainerHeight()
}) })
@@ -239,31 +353,35 @@ onMounted(() => {
} }
} }
// 初始化容器高度
updateContainerHeight() updateContainerHeight()
// 添加全局事件监听
window.addEventListener('resize', updateContainerHeight) window.addEventListener('resize', updateContainerHeight)
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
// 清理所有事件监听和定时器
if (containerRef.value) { if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll) containerRef.value.removeEventListener('scroll', handleScroll)
} }
// 清理 ResizeObserver
if (resizeObserver) { if (resizeObserver) {
resizeObserver.disconnect() resizeObserver.disconnect()
} }
window.removeEventListener('resize', updateContainerHeight) window.removeEventListener('resize', updateContainerHeight)
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
if (keyboardTimer) { window.removeEventListener('keyup', handleKeyUp)
clearTimeout(keyboardTimer) if (keyboardTimer) clearTimeout(keyboardTimer)
} if (hoverTimer) clearTimeout(hoverTimer)
if (keyPressTimer) clearTimeout(keyPressTimer)
}) })
</script> </script>
<template> <template>
<!-- 虚拟列表容器 -->
<div <div
ref="containerRef" ref="containerRef"
class="virtual-list-container" class="virtual-list-container"
@@ -271,21 +389,24 @@ onBeforeUnmount(() => {
:style="{ height: '100%' }" :style="{ height: '100%' }"
tabindex="0" tabindex="0"
> >
<!-- 虚拟高度占位元素 -->
<div <div
ref="listRef" ref="listRef"
class="virtual-list-phantom" class="virtual-list-phantom"
:style="{ height: `${phantomHeight}px` }" :style="{ height: needVirtualization ? `${phantomHeight}px` : 'auto' }"
> >
<!-- 实际渲染的列表内容 -->
<div <div
class="virtual-list" class="virtual-list"
:style="{ transform: `translateY(${offsetY}px)` }" :style="needVirtualization ? { transform: `translateY(${offsetY}px)` } : {}"
> >
<!-- 列表项 -->
<div <div
v-for="item in visibleData" v-for="item in visibleData"
:key="item.index" :key="item.index"
class="list-item" class="list-item"
:class="{ 'selected': selectedIndex === item.index }" :class="{ 'selected': selectedIndex === item.index }"
:style="{ height: `${config.itemHeight}px` }" :style="{ height: `${ITEM_HEIGHT}px` }"
@mouseenter="handleMouseEnter(item.index)" @mouseenter="handleMouseEnter(item.index)"
@click="handleClick(item)" @click="handleClick(item)"
@contextmenu="handleContextMenu($event, item)" @contextmenu="handleContextMenu($event, item)"
@@ -293,14 +414,40 @@ onBeforeUnmount(() => {
<slot name="item" :item="item"> <slot name="item" :item="item">
<!-- 默认列表项样式 --> <!-- 默认列表项样式 -->
<div class="default-item"> <div class="default-item">
<!-- 项目图标 -->
<div class="item-icon"> <div class="item-icon">
<svg viewBox="0 0 24 24" width="16" height="16"> <img
<circle cx="12" cy="12" r="10" fill="#1a1a1a" /> v-if="item.icon"
:src="item.icon"
:alt="item.name"
width="32"
height="32"
>
<svg v-else viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="rgba(0, 0, 0, 0.38)" />
</svg> </svg>
</div> </div>
<!-- 项目内容 -->
<div class="item-content"> <div class="item-content">
<!-- 标题行名称和标签 -->
<div class="item-header">
<div class="item-name">{{ item.name }}</div> <div class="item-name">{{ item.name }}</div>
<div class="item-path">{{ item.path }}</div> <!-- 标签列表 -->
<div class="item-tags" v-if="item.tags?.length">
<span
v-for="tag in item.tags"
:key="tag.id"
class="item-tag"
:style="{ backgroundColor: tag.color }"
>
{{ tag.name }}
</span>
</div>
</div>
<!-- 路径信息 -->
<div class="item-description">
<div class="item-description-text">{{ item.description }}</div>
</div>
</div> </div>
</div> </div>
</slot> </slot>
@@ -311,20 +458,24 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
/* 虚拟列表容器:可滚动区域 */
.virtual-list-container { .virtual-list-container {
overflow-y: auto; overflow-y: auto;
border: 1px solid #ccc; border: 1px solid #ccc;
position: relative; position: relative;
background-color: #f4f4f4; background-color: #fafafa;
width: 100%; width: 100%;
height: 100%; height: 100%;
outline: none;
} }
/* 拟高度容器:用于保持滚动条比例 */
.virtual-list-phantom { .virtual-list-phantom {
position: relative; position: relative;
width: 100%; width: 100%;
} }
/* 实际列表内容容器:通过 transform 实现位置偏移 */
.virtual-list { .virtual-list {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -332,21 +483,24 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
} }
/* 列表项基础样式 */
.list-item { .list-item {
padding: 8px 16px; padding: 8px 12px 8px 8px;
display: flex; display: flex;
align-items: center; align-items: center;
transition: background-color 0.2s ease, opacity 0.2s ease; transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer; cursor: pointer;
background-color: white; background-color: white;
border-radius: 4px;
margin: 0 4px;
} }
/* 简单的选中高亮效果 */ /* 选中状态样式 */
.list-item.selected { .list-item.selected {
background-color: rgba(64, 169, 255, 0.15); background-color: rgba(0, 0, 0, 0.04);
} }
/* 默认列表项样式 */ /* 列表项布局容器 */
.default-item { .default-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -354,15 +508,26 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
} }
/* 图标容器 */
.item-icon { .item-icon {
flex-shrink: 0; flex-shrink: 0;
width: 24px; width: 45px;
height: 24px; height: 45px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: rgba(0, 0, 0, 0.54);
overflow: hidden;
} }
/* 图标图片样式 */
.item-icon img {
width: 32px;
height: 32px;
object-fit: contain;
}
/* 内容区域布局 */
.item-content { .item-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -371,24 +536,70 @@ onBeforeUnmount(() => {
gap: 4px; gap: 4px;
} }
.item-name { /* 标题行布局 */
font-size: 14px; .item-header {
color: #1a1a1a; display: flex;
font-weight: 500; align-items: center;
min-width: 0;
} }
.item-path { /* 项目名称样式 */
font-size: 12px; .item-name {
color: #666; font-size: 14px;
color: rgba(0, 0, 0, 0.87);
font-weight: 400;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
margin-right: auto;
} }
/* 添加冻结状态的样式 */ /* 信息行布局 */
.item-description {
display: flex;
gap: 4px;
}
/* 描述文本样式 */
.item-description-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* 标签容器样式 */
.item-tags {
display: flex;
gap: 6px;
flex-wrap: nowrap;
flex-shrink: 0;
overflow: hidden;
margin-left: 12px;
}
/* 单个标签样式 */
.item-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 12px;
font-weight: 400;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #fff !important;
opacity: 0.9;
}
/* 列表冻结状态样式 */
.list-frozen { .list-frozen {
pointer-events: none; /* 禁用鼠标事件 */ pointer-events: none;
opacity: 0.7; /* 降低透明度表示冻结状态 */ opacity: 0.38;
user-select: none; /* 禁用文本选择 */ user-select: none;
} }
</style> </style>

7
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -1,4 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

8
src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(router)
app.mount('#app')

317
src/pages/Config.vue Normal file
View File

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

59
src/pages/Project.vue Normal file
View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import ProjectList from '@/components/ProjectList.vue'
import type { ListItem, MenuItem } from '@/types'
// 生成模拟数据
const listData: Ref<ListItem[]> = ref([])
const initListData = () => {
let samplePic = 'https://aisuda.bce.baidu.com/amis/static/favicon_b3b0647.png'
listData.value = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `project-${i + 1}`,
description: `这是项目 ${i + 1} 的详细描述信息`,
icon: samplePic,
tags: [
...(i % 2 === 0 ? [{ id: 1, name: 'Vue', color: '#42b883' }] : []),
...(i % 3 === 0 ? [{ id: 2, name: 'TypeScript', color: '#3178c6' }] : []),
...(i % 4 === 0 ? [{ id: 3, name: 'React', color: '#149eca' }] : []),
...(i % 5 === 0 ? [{ id: 4, name: 'Node.js', color: '#539e43' }] : []),
],
// 为每个列表项定义其特有的菜单项
menuItems: (i % 2 === 0 ? [
{
id: 'open',
label: '打开项目',
action: (item) => {
console.log('打开项目:', item?.name)
},
shortcut: 'alt+o'
},
{
id: 'edit',
label: '编辑项目',
action: (item) => {
console.log('编辑项目:', item?.name)
},
shortcut: 'ctrl+e'
},
{
id: 'delete',
label: '删除项目',
action: (item) => {
console.log('删除项目:', item?.name)
}
}
] : undefined)
}))
}
initListData()
// 列表项点击事件处理
const handleItemClick = (item: ListItem): void => {
console.log('点击列表项:', item)
}
</script>
<template>
<ProjectList :data="listData" @click="handleItemClick" />
</template>

27
src/router/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createWebHashHistory } from 'vue-router'
import { createRouter } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/project'
},
{
path: '/project',
name: 'project',
component: () => import('@/pages/Project.vue')
},
{
path: '/config',
name: 'config',
component: () => import('@/pages/Config.vue')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router

10
src/style.css Normal file
View File

@@ -0,0 +1,10 @@
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
#app {
height: 100%;
}

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

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

45
src/types/index.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* 标签信息接口
*/
export interface Tag {
/** 标签唯一标识 */
id: number
/** 标签名称 */
name: string
/** 标签颜色(十六进制颜色值) */
color: string
}
/**
* 列表项数据接口
*/
export interface ListItem {
/** 项目唯一标识 */
id: number
/** 项目名称 */
name: string
/** 项目描述 */
description: string
/** 项目图标 URL */
icon?: string
/** 项目标签列表 */
tags?: Tag[]
/** 在虚拟列表中的索引位置 */
index?: number
/** 菜单项列表 */
menuItems?: MenuItem[]
}
/**
* 菜单项接口
*/
export interface MenuItem {
/** 菜单项唯一标识 */
id: string
/** 菜单项显示文本 */
label: string
/** 菜单项点击处理函数,接收当前选中的列表项作为参数 */
action: (item: ListItem | null) => void
/** 快捷键,例如 'ctrl+c', 'command+v' 等 */
shortcut?: string
}

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

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

View File

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

40
tsconfig.json Normal file
View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": [
"ESNext",
"DOM"
],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
"types": [
"vue-router"
]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

14
tsconfig.node.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"lib": [
"ES2020"
]
},
"include": [
"vite.config.ts"
]
}

View File

@@ -1,7 +1,12 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
}) })