Compare commits

...
67 Commits
Author SHA1 Message Date
Hooray 47f1ac3513 chore: release v5.8.0 2025-10-08 12:43:12 +08:00
Hooray 314de32d79 chore: 更新依赖 2025-10-08 12:42:57 +08:00
Hooray 50db1c3091 fix: 修复明暗主题切换动画闪烁 2025-09-25 18:39:27 +08:00
Hooray c63ac44413 fix: 修复 FaSwitch 图标不居中 2025-09-22 16:33:40 +08:00
Hooray 8e2de47ee6 fix: 修复 FaImagePreview 预览时警告 2025-09-22 15:38:33 +08:00
Hooray 57ce39bf98 chore: release v5.7.0 2025-09-14 10:35:02 +08:00
Hooray 5253eed72c chore: 更新依赖 2025-09-13 17:56:11 +08:00
Hooray 6cae3ba328 refactor: 移除 FaScrollArea 组件中的垂直滚动逻辑 2025-09-12 14:43:51 +08:00
Hooray 5e4c1c20f4 chore: 增加英文 readme 2025-09-05 18:31:53 +08:00
Hooray aa5de0ebf0 feat: 新增 FaCollapsible 组件 2025-09-05 16:25:44 +08:00
Hooray 632a2d389f feat: FaCheckbox 支持半选状态 2025-09-05 16:15:20 +08:00
Hooray a16f9232f9 fix: 修复 FaFileUpload 和 FaImageUpload 在表单中点击可能会触发表单提交 2025-09-05 16:10:56 +08:00
Hooray ca25b1ae89 chore: 更新依赖 2025-09-01 10:11:59 +08:00
Hooray 16a5779b1f fix: 修复 FaPinInput 组件输入bug 2025-09-01 00:24:49 +08:00
Hooray 0fe0df52d2 refactor: 优化标签页快捷键跳转逻辑 2025-08-15 11:30:24 +08:00
Hooray 7b9dfe3eb8 feat: FaSelect 组件支持更多类型 2025-08-13 10:28:12 +08:00
Hooray 4a52c99290 refactor: 优化请求错误处理逻辑 2025-08-13 00:30:10 +08:00
Hooray 2d5e29eed7 chore: release v5.6.0 2025-08-10 10:53:00 +08:00
Hooray 263bc43e9a chore: 更新依赖 2025-08-10 10:52:05 +08:00
Hooray 3c8cb20cdc refactor: 优化响应拦截器逻辑 2025-08-08 17:45:08 +08:00
Hooray efc54f49ef feat: useFaModal 和 useFaDrawer 页面卸载时自动清理 2025-08-06 18:06:40 +08:00
Hooray 0b9d11fcfa fix: 确保 router.close 方法在 router.push 后处理 tabbar 状态 2025-08-05 23:44:10 +08:00
Hooray f5593aeea3 docs: 更新 2025-08-02 23:09:57 +08:00
Hooray 9bfe2fb2d8 feat: 增加 FaTextarea 组件 2025-08-02 14:49:05 +08:00
Hooray c19d749c61 feat: FaSlider 组件增加 tooltip 属性 2025-08-02 04:00:20 +08:00
Hooray 2e904eb8e4 style: 调整 FaDrawer 和 FaModal 组件的底部样式,增加内边距 2025-08-02 03:57:28 +08:00
Hooray 90e69b157a refactor: 优化次导航第一层最深路径解析逻辑 2025-07-31 23:05:46 +08:00
Hooray e086017689 refactor: 优化菜单项初始化逻辑 2025-07-30 23:52:05 +08:00
Hooray 3def174a9a fix: 修复 FaDrawer and FaModal 组件在头部不显示时的警告 2025-07-30 22:17:01 +08:00
Hooray 06458467c5 feat: FaModal 和 FaDrawer 组件添加 zIndex 属性 2025-07-30 21:43:55 +08:00
Hooray 9e9544bd70 refactor: 重构 useFaModal 和 useFaDrawer 2025-07-29 00:57:46 +08:00
Hooray 18119a4e86 refactor: 优化页面缓存逻辑 2025-07-28 20:32:14 +08:00
Hooray 9e5df6a747 chore: 更新依赖 2025-07-25 15:20:58 +08:00
Hooray d390daeead feat: FaModal 和 FaDrawer 分别增加命令式调用API 2025-07-25 14:09:50 +08:00
Hooray 6549b8ffbf refactor: 优化 tabId ,排除路由中的 query 参数 2025-07-21 10:21:36 +08:00
Hooray 2344fd78c1 chore: 更新依赖 2025-07-18 14:19:49 +08:00
Hooray 5551c703b2 fix: 修复 FaButtonGroup 组件的按钮边框样式,确保在非最后一个和非第一个按钮时正确应用圆角样式 2025-07-05 15:42:51 +08:00
Hooray 0e174e26ff feat!: 新增 FaFileUpload 组件,移除 FileUpload 组件 2025-07-05 15:41:05 +08:00
Hooray b153bda743 feat!: 新增 FaImageUpload 组件,移除 ImageUpload 和 ImagesUpload 组件 2025-07-03 23:56:01 +08:00
Hooray 43a85f5b39 feat: FaImagePreview 组件函数式调用增加多图支持 2025-07-02 15:16:54 +08:00
Hooray ccb8bd9a18 feat: FaImagePreview 组件增加函数式调用 2025-07-02 01:45:44 +08:00
Hooray cc9bb1a194 fix: 修复自动生成 store 文件目录错误 2025-06-30 15:21:07 +08:00
Hooray 35af83a82a chore: release v5.5.0 2025-06-29 18:22:54 +08:00
Hooray 128ac5ee46 chore: 更新依赖 2025-06-29 18:18:40 +08:00
Hooray fa537b7969 feat: vite 升级到 7.0 2025-06-26 17:38:57 +08:00
Hooray e2cd48469c feat: 标签页右键菜单补充图标 2025-06-25 18:33:53 +08:00
Hooray bfe6607f04 feat: 个人设置弹窗适配移动端 2025-06-24 10:11:19 +08:00
Hooray 59e683c2e3 refactor!: 扩展路由 close 函数,并移除 useTabbar 中 close 函数 2025-06-21 15:19:37 +08:00
Hooray 8d59733973 refactor!: pinia store 改为自动导入 2025-06-17 12:39:04 +08:00
Hooray 0ac47384aa refactor!: 移除 useViewTransition 函数封装 2025-06-16 11:37:59 +08:00
Hooray 1a0eb3989c feat!: 新增 FaImagePreview 组件,并移除 ImagePreview 组件 2025-06-15 23:52:20 +08:00
Hooray 6fc89426c9 feat: FaDrawer 和 FaModal 组件新增 destroyOnClose 属性 2025-06-13 16:07:19 +08:00
Hooray 28cc84ff0e fix: 修复账户按钮移动端次导航下不实现用户名 2025-06-12 11:30:31 +08:00
Hooray 094ee89c6c refactor: 优化主题样式和全局样式引入 2025-06-12 02:08:40 +08:00
Hooray 7ee93b0ccb perf: 优化导航菜单动效执行性能 2025-06-11 18:27:20 +08:00
Hooray e3aa58e892 feat: 为 FaTabs 组件添加 listClass 和 contentClass 属性,以支持自定义样式 2025-06-11 10:08:35 +08:00
Hooray 6febfea146 feat: FaModal 组件增加 beforeClose 回调函数,优化关闭逻辑并支持异步处理 2025-06-10 14:37:25 +08:00
Hooray 3859c26f42 fix: 修复权限未开启时菜单未过滤的问题,并简化菜单过滤逻辑 2025-06-09 23:49:35 +08:00
Hooray 245eda34cb refactor: FaModal 组件 provide/inject 改为组件传参方式 2025-06-09 11:43:40 +08:00
Hooray 2bf683d75d chore: 更新依赖 2025-06-09 01:17:55 +08:00
Hooray e553fefffb feat: 新增 FaButtonGroup 组件 2025-06-07 16:54:28 +08:00
Hooray 5160da3085 refactor: 使用 useId 优化菜单逻辑 2025-06-07 10:20:56 +08:00
Hooray 4ef21b7c7f fix: 更新viewport设置以改善移动设备上的显示效果 2025-06-06 16:33:24 +08:00
Hooray 202fc12e99 refactor: 首屏loading适配暗黑模式 2025-06-06 15:05:07 +08:00
Hooray 098d39b6bf refactor: 优化生成icon脚本 2025-06-04 14:09:27 +08:00
Hooray 53f7485754 chore: 更新依赖 2025-06-03 10:47:30 +08:00
Hooray b5981dd6ac chore: 更新 Node.js 版本要求 2025-06-03 10:06:50 +08:00
173 changed files with 11170 additions and 5992 deletions
+4 -4
View File
@@ -9,13 +9,13 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: latest
- run: npx changelogithub # or changelogithub@0.12 if ensure the stable result
env:
@@ -25,7 +25,7 @@ jobs:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -57,7 +57,7 @@ jobs:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: example
+129
View File
@@ -0,0 +1,129 @@
[中文](./README.md) | **English**
<p align="center">
<img src="https://fantastic-admin.hurui.me/logo.svg" width="200" height="200" />
</p>
<h1 align="center">Fantastic-admin</h1>
<p align="center">A <b>out-of-the-box</b> Vue3 back-office management system framework</p>
<p align="center">
<a href="https://fantastic-admin.hurui.me" target="_blank">Official Website</a>
<span>&nbsp;|&nbsp;</span>
<a href="https://fantastic-admin.pages.dev" target="_blank">Backup URL</a>
<p>
<p align="center">
<a href="###"><img src="https://img.shields.io/github/license/fantastic-admin/basic?label=License&style=flat-square" /></a>
<a href="https://github.com/fantastic-admin/basic/releases" target="_blank"><img src="https://img.shields.io/github/v/release/fantastic-admin/basic?label=Version&style=flat-square" /></a>
</p>
## Features
- Freely replaceable UI component library, default using Element Plus
- Rich layouts and themes, covering various back-office application scenarios in the market, compatible with PC, tablet and mobile
- Provides system configuration files for easy personalized customization
- Automatically generates navigation bar based on route configuration
- File system-based routing
- Supports comprehensive permission verification
- Built-in multi-level routing best caching solution
- Easy internationalization and multi-language adaptation
- Provides tab functionality with an experience close to native browser tab operations
## Download
> This repository is the basic version
**Directly pulling the source code may include unreleased content. It is recommended to download the stable version zip package from the [Github Releases](https://github.com/fantastic-admin/basic/releases) page**. If you are sure you need to pull the source code, please refer to the following branch descriptions:
- `main` Vue3 version framework source code branch, without example code, can be directly used for actual development
- `example` Vue3 version demo source code branch, same as the online demo site, contains many examples, can be used for reference learning
- ~~`vue2` Vue2 version framework source code branch, without example code, can be directly used for actual development~~ (discontinued)
- ~~`vue2-example` Vue2 version demo source code branch, contains many examples, can be used for reference learning~~ (discontinued)
## Preview
> Preview screenshots are from Vue3 Professional version
<table>
<tr>
<td><img src="https://fantastic-admin.hurui.me/preview1.png" /></td>
<td><img src="https://fantastic-admin.hurui.me/preview2.png" /></td>
<td><img src="https://fantastic-admin.hurui.me/preview3.png" /></td>
</tr>
<tr>
<td><img src="https://fantastic-admin.hurui.me/preview4.png" /></td>
<td><img src="https://fantastic-admin.hurui.me/preview5.png" /></td>
<td><img src="https://fantastic-admin.hurui.me/preview6.png" /></td>
</tr>
</table>
## Support
If you think the Fantastic-admin framework is good, or you are already using it, I hope you can give me a ⭐ on **Github**, which will be a great encouragement to me.
[![star](https://img.shields.io/github/stars/fantastic-admin/basic?style=social)](https://github.com/fantastic-admin/basic)
<details>
<summary>Github Stars Curve</summary>
[![Stargazers over time](https://starchart.cc/fantastic-admin/basic.svg)](https://starchart.cc/fantastic-admin/basic)
</details>
## Ecosystem
<table>
<tr>
<th colspan="3" align="center">
<a href="https://hooray.github.io/fantastic-startkit/" target="_blank">Fantastic-startkit</a>
</th>
</tr>
<tr>
<th colspan="3" align="center">
A simple and easy-to-use Vue3 project startup kit
</th>
</tr>
</table>
<table>
<tr>
<th colspan="3" align="center">
<a href="https://one-step-admin.hurui.me" target="_blank">One-step-admin</a>
</th>
</tr>
<tr>
<th colspan="3" align="center">
A Vue middle and back-office management system framework that's always one step ahead
</th>
</tr>
<tr>
<td><img src="https://one-step-admin.hurui.me/preview1.png" /></td>
<td><img src="https://one-step-admin.hurui.me/preview2.png" /></td>
<td><img src="https://one-step-admin.hurui.me/preview3.png" /></td>
</tr>
<tr>
<td><img src="https://one-step-admin.hurui.me/preview4.png" /></td>
<td><img src="https://one-step-admin.hurui.me/preview5.png" /></td>
<td><img src="https://one-step-admin.hurui.me/preview6.png" /></td>
</tr>
</table>
<table>
<tr>
<th colspan="4" align="center">
<a href="https://fantastic-mobile.hurui.me" target="_blank">Fantastic-mobile</a>
</th>
</tr>
<tr>
<th colspan="4" align="center">
A unique mobile H5 framework
</th>
</tr>
<tr>
<td><img src="https://fantastic-mobile.hurui.me/preview1.png" /></td>
<td><img src="https://fantastic-mobile.hurui.me/preview2.png" /></td>
<td><img src="https://fantastic-mobile.hurui.me/preview3.png" /></td>
<td><img src="https://fantastic-mobile.hurui.me/preview4.png" /></td>
</tr>
</table>
+4 -2
View File
@@ -1,3 +1,5 @@
**中文** | [English](./README.EN.md)
<p align="center">
<img src="https://fantastic-admin.hurui.me/logo.svg" width="200" height="200" />
</p>
@@ -37,8 +39,8 @@
- `main` Vue3 版本框架源码分支,不含示例代码,可直接用于实际开发
- `example` Vue3 版本演示源码分支,同线上演示站,包含大量示例,可用于参考学习
- `vue2` Vue2 版本框架源码分支,不含示例代码,可直接用于实际开发(停止维护)
- `vue2-example` Vue2 版本演示源码分支,包含大量示例,可用于参考学习(停止维护)
- ~~`vue2` Vue2 版本框架源码分支,不含示例代码,可直接用于实际开发~~(停止维护)
- ~~`vue2-example` Vue2 版本演示源码分支,包含大量示例,可用于参考学习~~(停止维护)
## 预览
-1
View File
@@ -6,7 +6,6 @@ export default antfu(
ignores: [
'public',
'dist*',
'src/ui/shadcn/ui',
],
},
{
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/browser_upgrade/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"/>
<meta http-equiv="Expires" content="0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Cache-control" content="no-cache">
+1 -1
View File
@@ -101,7 +101,7 @@
height: 100%;
color: var(--rainbow-color);
user-select: none;
background-color: snow;
background-color: hsl(var(--background));
}
.loading-container *::before,
+66 -64
View File
@@ -1,8 +1,9 @@
{
"type": "module",
"version": "5.4.0",
"version": "5.8.0",
"packageManager": "pnpm@10.18.1",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
@@ -12,7 +13,7 @@
"serve:test": "http-server ./dist-test -o",
"svgo": "svgo -f src/assets/icons",
"new": "plop",
"generate:icons": "esno ./scripts/generate.icons.ts",
"generate:icons": "tsx ./scripts/generate.icons.ts",
"lint": "npm-run-all -s lint:tsc lint:eslint lint:stylelint",
"lint:tsc": "vue-tsc -b",
"lint:eslint": "eslint . --cache --fix",
@@ -24,95 +25,96 @@
"release": "bumpp"
},
"dependencies": {
"@vee-validate/zod": "^4.15.0",
"@vueuse/components": "^13.3.0",
"@vueuse/core": "^13.3.0",
"@vueuse/integrations": "^13.3.0",
"axios": "^1.9.0",
"@vee-validate/zod": "^4.15.1",
"@vueuse/components": "^13.9.0",
"@vueuse/core": "^13.9.0",
"@vueuse/integrations": "^13.9.0",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"defu": "^6.1.4",
"disable-devtool": "^0.3.8",
"element-plus": "^2.9.11",
"eruda": "^3.4.1",
"es-toolkit": "^1.38.0",
"hotkeys-js": "^3.13.10",
"lucide-vue-next": "^0.511.0",
"disable-devtool": "^0.3.9",
"element-plus": "^2.11.4",
"eruda": "^3.4.3",
"es-toolkit": "^1.39.10",
"filesize": "^11.0.13",
"hotkeys-js": "^3.13.15",
"lucide-vue-next": "^0.545.0",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"path-to-regexp": "^8.2.0",
"pinia": "^3.0.2",
"path-to-regexp": "^8.3.0",
"pinia": "^3.0.3",
"qs": "^6.14.0",
"reka-ui": "^2.3.0",
"reka-ui": "^2.5.1",
"scule": "^1.3.0",
"tailwind-merge": "^3.3.0",
"ua-parser-js": "^2.0.3",
"tailwind-merge": "^3.3.1",
"ua-parser-js": "^2.0.5",
"vconsole": "^3.15.1",
"vee-validate": "^4.15.0",
"vue": "^3.5.16",
"vee-validate": "^4.15.1",
"vue": "^3.5.22",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.0",
"zod": "^3.25.41"
"vue-sonner": "^2.0.9",
"zod": "^4.1.12"
},
"devDependencies": {
"@antfu/eslint-config": "^4.13.2",
"@faker-js/faker": "^9.8.0",
"@iconify/json": "^2.2.342",
"@antfu/eslint-config": "^5.4.1",
"@clack/prompts": "^0.11.0",
"@faker-js/faker": "^10.0.0",
"@iconify/json": "^2.2.392",
"@iconify/vue": "^5.0.0",
"@stylistic/stylelint-config": "^2.0.0",
"@stylistic/stylelint-config": "^3.0.1",
"@types/nprogress": "^0.2.3",
"@types/path-browserify": "^1.0.3",
"@types/qs": "^6.14.0",
"@unocss/eslint-plugin": "^66.1.2",
"@unocss/preset-legacy-compat": "^66.1.2",
"@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@vue/tsconfig": "^0.7.0",
"@unocss/eslint-plugin": "^66.5.2",
"@unocss/preset-legacy-compat": "^66.5.2",
"@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"boxen": "^8.0.1",
"bumpp": "^10.1.1",
"cz-git": "^1.11.1",
"eslint": "^9.27.0",
"esno": "^4.8.0",
"fs-extra": "^11.3.0",
"bumpp": "^10.3.1",
"cz-git": "^1.12.0",
"eslint": "^9.37.0",
"fs-extra": "^11.3.2",
"http-server": "^14.1.1",
"inquirer": "^12.6.3",
"lint-staged": "^16.1.0",
"lint-staged": "^16.2.3",
"npm-run-all2": "^8.0.4",
"picocolors": "^1.1.1",
"plop": "^4.0.1",
"postcss": "^8.5.4",
"plop": "^4.0.4",
"postcss": "^8.5.6",
"postcss-nested": "^7.0.2",
"sass-embedded": "^1.89.0",
"simple-git-hooks": "^2.13.0",
"stylelint": "^16.20.0",
"stylelint-config-recess-order": "^6.0.0",
"stylelint-config-standard-scss": "^15.0.1",
"sass-embedded": "^1.93.2",
"simple-git-hooks": "^2.13.1",
"stylelint": "^16.25.0",
"stylelint-config-recess-order": "^7.3.0",
"stylelint-config-standard-scss": "^16.0.0",
"stylelint-config-standard-vue": "^1.0.0",
"stylelint-scss": "^6.12.0",
"svgo": "^3.3.2",
"taze": "^19.1.0",
"typescript": "^5.8.3",
"unocss": "^66.1.2",
"stylelint-scss": "^6.12.1",
"svgo": "^4.0.0",
"taze": "^19.7.0",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"unocss": "^66.5.2",
"unocss-preset-animations": "^1.2.1",
"unplugin-auto-import": "^19.3.0",
"unplugin-turbo-console": "^2.1.3",
"unplugin-vue-components": "^28.7.0",
"vite": "^6.3.5",
"vite-plugin-app-loading": "^0.3.1",
"vite-plugin-archiver": "^0.1.2",
"unplugin-auto-import": "^20.2.0",
"unplugin-turbo-console": "^2.2.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.9",
"vite-plugin-app-loading": "^0.4.0",
"vite-plugin-archiver": "^0.2.0",
"vite-plugin-banner": "^0.8.1",
"vite-plugin-compression2": "^1.4.0",
"vite-plugin-compression2": "^2.2.1",
"vite-plugin-env-parse": "^1.0.15",
"vite-plugin-fake-server": "^2.2.0",
"vite-plugin-pages": "^0.33.0",
"vite-plugin-pages": "^0.33.1",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-devtools": "^7.7.6",
"vite-plugin-vue-meta-layouts": "^0.5.1",
"vue-tsc": "^2.2.10"
"vite-plugin-vue-devtools": "^8.0.2",
"vite-plugin-vue-meta-layouts": "^0.6.0",
"vue-tsc": "^3.1.1"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged",
+1 -3
View File
@@ -1,4 +1,4 @@
const use{{ properCase name }}Store = defineStore(
export const use{{ properCase name }}Store = defineStore(
// 唯一ID
'{{ camelCase name }}',
() => {
@@ -9,5 +9,3 @@ const use{{ properCase name }}Store = defineStore(
}
},
)
export default use{{ properCase name }}Store
+8629 -4127
View File
File diff suppressed because it is too large Load Diff
+52 -19
View File
@@ -1,8 +1,8 @@
import { exec } from 'node:child_process'
import { spawn } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import * as p from '@clack/prompts'
import { lookupCollection, lookupCollections } from '@iconify/json'
import { checkbox, confirm } from '@inquirer/prompts'
import fs from 'fs-extra'
// 拿到全部图标集的原始数据
@@ -11,32 +11,48 @@ const raw = await lookupCollections()
let lastChoose = fs.readFileSync(path.resolve(process.cwd(), 'src/iconify/index.json'), 'utf-8')
lastChoose = JSON.parse(lastChoose)
// 取出可使用的图标集数据用于 inquirer 选择,并按名称排序
// 取出可使用的图标集数据用于选择,并按名称排序
const collections = Object.entries(raw).map(([id, item]) => ({
...item,
id,
})).sort((a, b) => a.name.localeCompare(b.name))
p.intro('图标集生成工具')
/**
* 分别会在对应目录下生成以下文件,其中(1)(3)用于离线下载并安装图标,(2)用于图标选择器使用
* (1) src/iconify/index.json 记录用户 inquirer 的交互信息
* (1) src/iconify/index.json 记录用户交互信息
* (2) src/iconify/data.json 包含多个图标集数据,仅记录图标名
* (3) public/icons/*-raw.json 多个图标集的原始数据,独立存放,用于离线使用
*/
const answers = {
collections: await checkbox({
message: '请选择需要生成的图标集',
choices: collections.map(item => ({
name: `${item.name} (${item.total} icons)`,
value: item.id,
checked: lastChoose.collections.includes(item.id),
})),
}),
isOfflineUse: await confirm({
message: '是否需要离线使用',
default: false,
}),
}
const answers = await p.group(
{
collections: () =>
p.multiselect({
message: '请选择需要生成的图标集',
options: collections.map(item => ({
label: item.name,
value: item.id,
hint: `${item.total} 个图标`,
})),
initialValues: lastChoose.collections,
}),
isOfflineUse: () =>
p.confirm({
message: '是否需要离线使用',
initialValue: false,
}),
},
{
onCancel: () => {
p.cancel('操作已取消')
process.exit(0)
},
},
)
const spinner = p.spinner()
spinner.start('正在生成图标集...')
await fs.writeJSON(
path.resolve(process.cwd(), 'src/iconify/index.json'),
@@ -72,4 +88,21 @@ await fs.writeJSON(
collectionsMeta,
)
exec('eslint src/iconify/data.json src/iconify/index.json --cache --fix')
// 使用 spawn 运行 eslint
const eslint = spawn('eslint', ['src/iconify/data.json', 'src/iconify/index.json', '--cache', '--fix'], {
stdio: 'inherit',
shell: true,
})
eslint.on('error', (err) => {
p.log.error(`ESLint 执行失败: ${err.message}`)
process.exit(1)
})
eslint.on('close', (code) => {
if (code !== 0) {
p.log.error(`ESLint 执行失败,退出码: ${code}`)
process.exit(code)
}
spinner.stop('图标集生成完成!')
})
-1
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import { ua } from '@/utils/ua'
import Provider from './ui/provider/index.vue'
+27 -28
View File
@@ -1,7 +1,6 @@
import axios from 'axios'
// import qs from 'qs'
import { toast } from 'vue-sonner'
import useUserStore from '@/store/modules/user'
// 请求重试配置
const MAX_RETRY_COUNT = 3 // 最大重试次数
@@ -45,21 +44,22 @@ api.interceptors.request.use(
function handleError(error: any) {
if (error.status === 401) {
useUserStore().requestLogout()
throw error
}
let message = error.message
if (message === 'Network Error') {
message = '后端网络故障'
else {
let message = error.message
if (message === 'Network Error') {
message = '后端网络故障'
}
else if (message.includes('timeout')) {
message = '接口请求超时'
}
else if (message.includes('Request failed with status code')) {
message = `接口${message.substr(message.length - 3)}异常`
}
toast.error('Error', {
description: message,
})
}
else if (message.includes('timeout')) {
message = '接口请求超时'
}
else if (message.includes('Request failed with status code')) {
message = `接口${message.substr(message.length - 3)}异常`
}
toast.error('Error', {
description: message,
})
return Promise.reject(error)
}
@@ -71,42 +71,41 @@ api.interceptors.response.use(
* 规则是当 status 为 1 时表示请求成功,为 0 时表示接口需要登录或者登录状态失效,需要重新登录
* 请求出错时 error 会返回错误信息
*/
if (response.data.status === 1) {
if (response.data.error !== '') {
toast.warning('Warning', {
description: response.data.error,
})
return Promise.reject(response.data)
if (typeof response.data === 'object') {
if (response.data.status === 1) {
if (response.data.error !== '') {
toast.warning('Warning', {
description: response.data.error,
})
return Promise.reject(response.data)
}
}
else {
useUserStore().requestLogout()
}
return Promise.resolve(response.data)
}
else {
useUserStore().requestLogout()
return Promise.reject(response.data)
}
return Promise.resolve(response.data)
},
async (error) => {
// 获取请求配置
const config = error.config
// 如果配置不存在或未启用重试,则直接处理错误
if (!config || !config.retry) {
return handleError(error)
}
// 设置重试次数
config.retryCount = config.retryCount || 0
// 判断是否超过重试次数
if (config.retryCount >= MAX_RETRY_COUNT) {
return handleError(error)
}
// 重试次数自增
config.retryCount += 1
// 延迟重试
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY))
// 重新发起请求
return api(config)
},
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200.781" height="200" class="icon" viewBox="0 0 1028 1024"><path d="M989.867 234.667H499.2c-17.067 0-34.133-21.334-34.133-42.667 0-25.6 12.8-42.667 34.133-42.667h490.667c17.066 0 34.133 17.067 34.133 42.667 0 21.333-12.8 42.667-34.133 42.667zm-473.6 128h465.066c25.6 0 46.934 21.333 46.934 42.666 0 25.6-21.334 42.667-46.934 42.667H516.267c-25.6 0-46.934-17.067-46.934-42.667s21.334-42.666 46.934-42.666zm0 298.666c-25.6 0-46.934-21.333-46.934-42.666 0-25.6 21.334-42.667 46.934-42.667h465.066c25.6 0 46.934 17.067 46.934 42.667s-21.334 42.666-46.934 42.666H516.267zm4.266 128H972.8c29.867 0 51.2 17.067 51.2 42.667s-21.333 42.667-51.2 42.667H520.533c-29.866 0-51.2-17.067-51.2-42.667s21.334-42.667 51.2-42.667zm-192 25.6c-17.066 17.067-46.933 17.067-64 0L12.8 541.867c-17.067-17.067-17.067-51.2 0-68.267l251.733-273.067c17.067-17.066 46.934-17.066 64 0s17.067 51.2 0 68.267L106.667 507.733l221.866 238.934c17.067 21.333 17.067 51.2 0 68.266z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="200.781" height="200" class="icon" viewBox="0 0 1028 1024"><path d="M989.867 234.667H499.2c-17.067 0-34.133-21.334-34.133-42.667 0-25.6 12.8-42.667 34.133-42.667h490.667c17.066 0 34.133 17.067 34.133 42.667 0 21.333-12.8 42.667-34.133 42.667m-473.6 128h465.066c25.6 0 46.934 21.333 46.934 42.666 0 25.6-21.334 42.667-46.934 42.667H516.267c-25.6 0-46.934-17.067-46.934-42.667s21.334-42.666 46.934-42.666m0 298.666c-25.6 0-46.934-21.333-46.934-42.666 0-25.6 21.334-42.667 46.934-42.667h465.066c25.6 0 46.934 17.067 46.934 42.667s-21.334 42.666-46.934 42.666zm4.266 128H972.8c29.867 0 51.2 17.067 51.2 42.667s-21.333 42.667-51.2 42.667H520.533c-29.866 0-51.2-17.067-51.2-42.667s21.334-42.667 51.2-42.667m-192 25.6c-17.066 17.067-46.933 17.067-64 0L12.8 541.867c-17.067-17.067-17.067-51.2 0-68.267l251.733-273.067c17.067-17.066 46.934-17.066 64 0s17.067 51.2 0 68.267L106.667 507.733l221.866 238.934c17.067 21.333 17.067 51.2 0 68.266"/></svg>

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 986 B

+26 -26
View File
@@ -1,5 +1,7 @@
/* 页面布局 CSS 变量 */
:root {
color-scheme: light;
/* 头部高度 */
--g-header-height: 60px;
@@ -16,44 +18,45 @@
/* 工具栏高度 */
--g-toolbar-height: 50px;
/* 滚动条颜色 */
--scrollbar-color: 240 5.9% 90%;
&.dark {
color-scheme: dark;
--scrollbar-color: 240 3.7% 15.9%;
}
}
/* 明暗模式 CSS 变量 */
/* stylelint-disable-next-line no-duplicate-selectors */
:root {
color-scheme: light;
::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
animation: none;
}
&::view-transition-old(root),
&::view-transition-new(root) {
mix-blend-mode: normal;
animation: none;
}
::view-transition-old(root) {
z-index: 0;
}
::view-transition-new(root) {
z-index: 1;
}
.dark {
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 9999;
}
&.dark {
color-scheme: dark;
&::view-transition-old(root) {
z-index: 9999;
}
&::view-transition-new(root) {
z-index: 1;
}
z-index: 0;
}
}
* {
scrollbar-color: hsl(var(--border)) transparent;
scrollbar-color: hsl(var(--scrollbar-color)) transparent;
scrollbar-width: thin;
border-color: hsl(var(--border));
}
html,
@@ -64,9 +67,6 @@ body {
body {
box-sizing: border-box;
margin: 0;
font-family: var(--font-family);
color: hsl(var(--foreground));
background: hsl(var(--background));
-webkit-tap-highlight-color: transparent;
}
+2 -4
View File
@@ -1,7 +1,5 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import useSettingsStore from '@/store/modules/settings'
import useUserStore from '@/store/modules/user'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
import Profile from './profile.vue'
@@ -67,7 +65,7 @@ const isProfileShow = ref(false)
<FaAvatar :src="userStore.avatar" :class="cn('size-6', { 'size-full': onlyAvatar })">
<FaIcon name="i-carbon:user-avatar-filled" class="size-6 text-secondary-foreground/50" />
</FaAvatar>
<div v-if="settingsStore.mode === 'pc' && !onlyAvatar" class="min-w-0 flex-center-between flex-1 gap-2">
<div v-if="!onlyAvatar" class="min-w-0 flex-center-between flex-1 gap-2">
<div class="flex-1 truncate text-start">
{{ userStore.account }}
</div>
@@ -75,7 +73,7 @@ const isProfileShow = ref(false)
</div>
</FaButton>
</FaDropdown>
<FaModal v-model="isProfileShow" align-center :header="false" :footer="false" :close-on-click-overlay="false" :close-on-press-escape="false" class="h-500px min-w-600px overflow-hidden" content-class="min-h-full p-0 flex">
<FaModal v-model="isProfileShow" align-center :header="false" :footer="false" :close-on-click-overlay="false" :close-on-press-escape="false" class="h-500px max-w-xl overflow-hidden" content-class="min-h-full p-0 flex">
<Profile />
</FaModal>
</template>
+3 -3
View File
@@ -16,8 +16,8 @@ const tabs = ref([
<template>
<div class="min-h-full w-full">
<div class="fixed inset-s-0 bottom-0 top-0 h-full w-40 border-e">
<div v-for="(tab, index) in tabs" :key="index" class="cursor-pointer px-4 py-3 transition-background-color space-y-2 hover-bg-accent/50" :class="{ 'bg-accent hover-bg-accent!': active === index }" @click="active = index">
<div class="fixed right-0 top-0 z-1 flex flex-row overflow-auto border-b border-e bg-background md:(inset-s-0 bottom-0 h-full w-40 flex-col)">
<div v-for="(tab, index) in tabs" :key="index" class="flex-shrink-0 cursor-pointer px-4 py-3 transition-background-color space-y-2 hover-bg-accent/50" :class="{ 'bg-accent hover-bg-accent!': active === index }" @click="active = index">
<div class="text-base text-accent-foreground leading-tight">
{{ tab.title }}
</div>
@@ -26,7 +26,7 @@ const tabs = ref([
</div>
</div>
</div>
<div class="ms-40 min-h-full flex-col-center p-10">
<div class="min-h-full flex-col-center p-10 pt-20 md:(ms-40 pt-10)">
<div v-if="active === 0">
请开发者自行扩展
</div>
@@ -3,7 +3,6 @@ import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import * as z from 'zod'
import useUserStore from '@/store/modules/user'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
-1
View File
@@ -2,7 +2,6 @@
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import useUserStore from '@/store/modules/user'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
+1 -1
View File
@@ -2,7 +2,7 @@
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
name: 'RegisterForm',
-143
View File
@@ -1,143 +0,0 @@
<script setup lang="ts">
import type { UploadProps, UploadUserFile } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'FileUpload',
})
const props = withDefaults(
defineProps<{
action: UploadProps['action']
headers?: UploadProps['headers']
data?: UploadProps['data']
name?: UploadProps['name']
size?: number
max?: number
files?: UploadUserFile[]
notip?: boolean
ext?: string[]
httpRequest?: UploadProps['httpRequest']
}>(),
{
name: 'file',
size: 2,
max: 3,
files: () => [],
notip: false,
ext: () => ['zip', 'rar'],
},
)
const emits = defineEmits<{
onSuccess: [
res: any,
file: UploadUserFile,
fileList: UploadUserFile[],
]
onRemove: [
file: UploadUserFile,
fileList: UploadUserFile[],
]
}>()
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const fileName = file.name.split('.')
const fileExt = fileName.at(-1) ?? ''
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`)
}
if (!isSizeOk) {
toast.error(`上传文件大小不能超过 ${props.size}MB!`)
}
return isTypeOk && isSizeOk
}
const onExceed: UploadProps['onExceed'] = () => {
toast.warning('文件上传超过限制')
}
const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
emits('onSuccess', res, file, fileList)
}
const onRemove: UploadProps['onRemove'] = (file, fileList) => {
emits('onRemove', file, fileList)
}
const onPreview: UploadProps['onPreview'] = (e) => {
const getBlob = (url: string) => new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'blob'
xhr.onload = () => {
if (xhr.status === 200) {
resolve(xhr.response)
}
}
xhr.send()
})
getBlob(e.url!).then((blob: any) => {
const a = document.createElement('a')
const url = window.URL.createObjectURL(blob)
const event = new MouseEvent('click')
a.target = '_blank'
a.download = e.name
a.href = url
a.dispatchEvent(event)
})
}
</script>
<template>
<ElUpload
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-exceed="onExceed"
:on-success="onSuccess"
:on-remove="onRemove"
:on-preview="onPreview"
:http-request="httpRequest"
:file-list="files"
:limit="max"
drag
>
<div class="slot">
<FaIcon name="i-ep:upload-filled" class="el-icon--upload" />
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
</div>
<template #tip>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<ElAlert :title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`" type="info" show-icon :closable="false" />
</div>
</div>
</template>
</ElUpload>
</template>
<style scoped>
:deep(.el-upload.is-drag) {
display: inline-block;
.el-upload-dragger {
padding: 0;
}
&.is-dragover {
border-width: 1px;
}
.slot {
width: 300px;
padding: 40px 0;
}
}
</style>
-63
View File
@@ -1,63 +0,0 @@
<script setup lang="ts">
defineOptions({
name: 'ImagePreview',
})
const props = withDefaults(
defineProps<{
src: string
width?: number | string
height?: number | string
}>(),
{
width: 200,
height: 200,
},
)
const realWidth = computed(() => {
return typeof props.width === 'string' ? props.width : `${props.width}px`
})
const realHeight = computed(() => {
return typeof props.height === 'string' ? props.height : `${props.height}px`
})
</script>
<template>
<ElImage :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]" preview-teleported>
<template #error>
<div class="image-slot">
<FaIcon name="i-ph:image-broken-duotone" />
</div>
</template>
</ElImage>
</template>
<style scoped>
.el-image {
background-color: var(--el-fill-color);
border-radius: 5px;
box-shadow: var(--el-box-shadow-light);
transition: background-color 0.3s, var(--el-transition-box-shadow);
:deep(.el-image__inner) {
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: scale(1.2);
}
}
:deep(.image-slot) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 30px;
color: #909399;
}
}
</style>
-253
View File
@@ -1,253 +0,0 @@
<script setup lang="ts">
import type { UploadProps } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'ImageUpload',
})
const props = withDefaults(
defineProps<{
action: UploadProps['action']
headers?: UploadProps['headers']
data?: UploadProps['data']
name?: UploadProps['name']
size?: number
width?: number
height?: number
placeholder?: string
notip?: boolean
ext?: string[]
httpRequest?: UploadProps['httpRequest']
}>(),
{
name: 'file',
size: 2,
width: 150,
height: 150,
placeholder: '',
notip: false,
ext: () => ['jpg', 'png', 'gif', 'bmp'],
},
)
const emits = defineEmits<{
onSuccess: [
res: any,
]
}>()
const url = defineModel<string>({
default: '',
})
const uploadData = ref({
imageViewerVisible: false,
progress: {
preview: '',
percent: 0,
},
})
// 预览
function preview() {
uploadData.value.imageViewerVisible = true
}
// 关闭预览
function previewClose() {
uploadData.value.imageViewerVisible = false
}
// 移除
function remove() {
url.value = ''
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const fileName = file.name.split('.')
const fileExt = fileName.at(-1) ?? ''
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
toast.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
}
if (!isSizeOk) {
toast.error(`上传图片大小不能超过 ${props.size}MB!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
}
return isTypeOk && isSizeOk
}
const onProgress: UploadProps['onProgress'] = (file) => {
uploadData.value.progress.percent = ~~file.percent
}
const onSuccess: UploadProps['onSuccess'] = (res) => {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emits('onSuccess', res)
}
</script>
<template>
<div class="upload-container">
<ElUpload
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
:http-request="httpRequest"
drag
class="image-upload"
>
<ElImage v-if="url === ''" :src="url === '' ? placeholder : url" :style="`width:${width}px;height:${height}px;`" fit="fill">
<template #error>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<FaIcon name="i-ep:plus" class="icon" />
</div>
</template>
</ElImage>
<div v-else class="image">
<ElImage :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<div class="mask">
<div class="actions">
<span @click.stop="preview">
<FaIcon name="i-ep:zoom-in" class="icon" />
</span>
<span @click.stop="remove">
<FaIcon name="i-ep:delete" class="icon" />
</span>
</div>
</div>
</div>
<div v-show="url === '' && uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</ElUpload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`" type="info" show-icon :closable="false" />
</div>
</div>
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="[url]" teleported @close="previewClose" />
</div>
</template>
<style scoped>
.upload-container {
line-height: initial;
}
.el-image {
display: block;
}
.image {
position: relative;
overflow: hidden;
border-radius: 6px;
.mask {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
opacity: 0;
transition: opacity 0.3s;
.actions {
position: absolute;
top: 50%;
left: 50%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
transform: translateX(-50%) translateY(-50%);
span {
width: 50%;
color: var(--el-color-white);
text-align: center;
cursor: pointer;
transition: color 0.1s, transform 0.1s;
&:hover {
transform: scale(1.5);
}
.icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.image-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
.icon {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: "";
background-color: var(--el-overlay-color-lighter);
}
.el-progress {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
transform: translateX(-50%) translateY(-50%);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
}
}
</style>
-278
View File
@@ -1,278 +0,0 @@
<script setup lang="ts">
import type { UploadProps } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'ImagesUpload',
})
const props = withDefaults(
defineProps<{
action: UploadProps['action']
headers?: UploadProps['headers']
data?: UploadProps['data']
name?: UploadProps['name']
size?: number
max?: number
width?: number
height?: number
placeholder?: string
notip?: boolean
ext?: string[]
httpRequest?: UploadProps['httpRequest']
}>(),
{
name: 'file',
size: 2,
max: 3,
width: 150,
height: 150,
placeholder: '',
notip: false,
ext: () => ['jpg', 'png', 'gif', 'bmp'],
},
)
const emits = defineEmits<{
onSuccess: [
res: any,
]
}>()
const url = defineModel<string[]>({
default: [],
})
const uploadData = ref({
dialogImageIndex: 0,
imageViewerVisible: false,
progress: {
preview: '',
percent: 0,
},
})
// 预览
function preview(index: number) {
uploadData.value.dialogImageIndex = index
uploadData.value.imageViewerVisible = true
}
// 关闭预览
function previewClose() {
uploadData.value.imageViewerVisible = false
}
// 移除
function remove(index: number) {
url.value.splice(index, 1)
}
// 移动
function move(index: number, type: 'left' | 'right') {
if (type === 'left' && index !== 0) {
url.value[index] = url.value.splice(index - 1, 1, url.value[index])[0]
}
if (type === 'right' && index !== url.value.length - 1) {
url.value[index] = url.value.splice(index + 1, 1, url.value[index])[0]
}
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const fileName = file.name.split('.')
const fileExt = fileName.at(-1) ?? ''
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
toast.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
}
if (!isSizeOk) {
toast.error(`上传图片大小不能超过 ${props.size}MB!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
}
return isTypeOk && isSizeOk
}
const onProgress: UploadProps['onProgress'] = (file) => {
uploadData.value.progress.percent = ~~file.percent
}
const onSuccess: UploadProps['onSuccess'] = (res) => {
uploadData.value.progress.preview = ''
uploadData.value.progress.percent = 0
emits('onSuccess', res)
}
</script>
<template>
<div class="upload-container">
<div v-for="(item, index) in (url as string[])" :key="index" class="images">
<ElImage v-if="index < max" :src="item" :style="`width:${width}px;height:${height}px;`" fit="cover" />
<div class="mask">
<div class="actions">
<span @click="preview(index)">
<FaIcon name="i-ep:zoom-in" class="icon" />
</span>
<span @click="remove(index)">
<FaIcon name="i-ep:delete" class="icon" />
</span>
<span v-show="url.length > 1" :class="{ disabled: index === 0 }" @click="move(index, 'left')">
<FaIcon name="i-ep:back" class="icon" />
</span>
<span v-show="url.length > 1" :class="{ disabled: index === url.length - 1 }" @click="move(index, 'right')">
<FaIcon name="i-ep:right" class="icon" />
</span>
</div>
</div>
</div>
<ElUpload
v-show="url.length < max"
:show-file-list="false"
:headers="headers"
:action="action"
:data="data"
:name="name"
:before-upload="beforeUpload"
:on-progress="onProgress"
:on-success="onSuccess"
:http-request="httpRequest"
drag
class="images-upload"
>
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
<FaIcon name="i-ep:plus" class="icon" />
</div>
<div v-show="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
</div>
</ElUpload>
<div v-if="!notip" class="el-upload__tip">
<div style="display: inline-block;">
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`" type="info" show-icon :closable="false" />
</div>
</div>
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="url as string[]" :initial-index="uploadData.dialogImageIndex" teleported @close="previewClose" />
</div>
</template>
<style scoped>
.upload-container {
line-height: initial;
}
.el-image {
display: block;
}
.images {
position: relative;
display: inline-block;
margin-inline-end: 10px;
overflow: hidden;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
.mask {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: var(--el-overlay-color-lighter);
opacity: 0;
transition: opacity 0.3s;
.actions {
position: absolute;
top: 50%;
left: 50%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
transform: translateX(-50%) translateY(-50%);
span {
width: 50%;
color: var(--el-color-white);
text-align: center;
cursor: pointer;
transition: color 0.1s, transform 0.1s;
&.disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
&:hover:not(.disabled) {
transform: scale(1.5);
}
.icon {
font-size: 24px;
}
}
}
}
&:hover .mask {
opacity: 1;
}
}
.images-upload {
display: inline-block;
vertical-align: top;
}
:deep(.el-upload) {
.el-upload-dragger {
display: inline-block;
padding: 0;
&.is-dragover {
border-width: 1px;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background-color: transparent;
.icon {
font-size: 30px;
}
}
.progress {
position: absolute;
top: 0;
&::after {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: "";
background-color: var(--el-overlay-color-lighter);
}
.el-progress {
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
transform: translateX(-50%) translateY(-50%);
.el-progress__text {
color: var(--el-text-color-placeholder);
}
}
}
}
}
</style>
File diff suppressed because one or more lines are too long
+1 -3
View File
@@ -2,8 +2,6 @@
import { useClipboard } from '@vueuse/core'
import { toast } from 'vue-sonner'
import settingsDefault from '@/settings.default'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import eventBus from '@/utils/eventBus'
import { diffTwoObj } from '@/utils/object'
@@ -58,7 +56,7 @@ function handleCopy() {
</script>
<template>
<FaDrawer v-model="isShow" title="应用配置" description="在生产环境中应关闭该模块" :footer="isSupported" content-class="sm:min-w-md">
<FaDrawer v-model="isShow" title="应用配置" description="在生产环境中应关闭该模块" :footer="isSupported" :destroy-on-close="false" content-class="sm:min-w-md">
<div class="rounded-2 bg-rose/20 px-4 py-2 text-sm/6 c-rose">
应用配置可实时预览效果,但只是临时生效,要想真正应用于项目,可以点击下方的「复制配置」按钮,并将配置粘贴到 src/settings.ts 文件中。
</div>
-2
View File
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import Logo from '../Logo/index.vue'
defineOptions({
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import eventBus from '@/utils/eventBus'
defineOptions({
-1
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import imgLogo from '@/assets/images/logo.svg'
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'Logo',
+1 -5
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import hotkeys from 'hotkeys-js'
import { useSlots } from '@/slots'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import Logo from '../Logo/index.vue'
defineOptions({
@@ -40,7 +38,7 @@ onUnmounted(() => {
<component :is="useSlots('main-sidebar-top')" />
<Logo :show-title="false" class="sidebar-logo" />
<component :is="useSlots('main-sidebar-after-logo')" />
<FaScrollArea :scrollbar="false" mask gradient-color="var(--g-main-sidebar-bg)" class="menu">
<FaScrollArea :scrollbar="false" mask gradient-color="var(--g-main-sidebar-bg)" class="menu flex-1">
<!-- 侧边栏模式(含主导航) -->
<div class="w-full flex flex-col of-hidden py-1 transition-all -mt-2">
<template v-for="(item, index) in menuStore.allMenus" :key="index">
@@ -92,8 +90,6 @@ onUnmounted(() => {
}
.menu {
flex: 1;
:deep(.menu-item) {
.menu-item-container {
padding-block: 8px;
+16 -5
View File
@@ -20,6 +20,16 @@ const props = withDefaults(
},
)
// 用于缓存对象到 ID 的映射
const idMap = new WeakMap<object, string>()
// 获取对象的唯一 ID,如果已存在则返回缓存的 ID,否则生成新的
function getUseId(obj: object): string {
if (!idMap.has(obj)) {
idMap.set(obj, useId())
}
return idMap.get(obj)!
}
const activeIndex = ref<MenuInjection['activeIndex']>(props.value)
const items = ref<MenuInjection['items']>({})
const subMenus = ref<MenuInjection['subMenus']>({})
@@ -32,8 +42,8 @@ const isMenuPopup = computed<MenuInjection['isMenuPopup']>(() => {
// 解析传入的 menu 数据,并保存到 items 和 subMenus 对象中
function initItems(menu: MenuProps['menu'], parentPaths: string[] = []) {
menu.forEach((item) => {
const index = item.path ?? JSON.stringify(item)
if (item.children) {
const index = item.path ?? getUseId(item)
if (item.children?.some(item => item.meta?.menu !== false)) {
const indexPath = [...parentPaths, index]
subMenus.value[index] = {
index,
@@ -144,6 +154,7 @@ watch(() => props.collapse, (value) => {
provide(rootMenuInjectionKey, reactive({
props,
getUseId,
items,
subMenus,
activeIndex,
@@ -164,10 +175,10 @@ provide(rootMenuInjectionKey, reactive({
'py-1': props.mode === 'vertical',
})"
>
<template v-for="item in menu" :key="item.path ?? JSON.stringify(item)">
<template v-for="item in menu" :key="item.path ?? getUseId(item)">
<template v-if="item.meta?.menu !== false">
<SubMenu v-if="item.children?.length" :menu="item" :unique-key="[item.path ?? JSON.stringify(item)]" />
<Item v-else :item="item" :unique-key="[item.path ?? JSON.stringify(item)]" @click="handleMenuItemClick(item.path ?? JSON.stringify(item))" />
<SubMenu v-if="item.children?.length" :menu="item" :unique-key="[item.path ?? getUseId(item)]" />
<Item v-else :item="item" :unique-key="[item.path ?? getUseId(item)]" @click="handleMenuItemClick(item.path ?? getUseId(item))" />
</template>
</template>
</div>
+139 -123
View File
@@ -16,111 +16,113 @@ const props = withDefaults(
},
)
const index = props.menu.path ?? JSON.stringify(props.menu)
const itemRef = useTemplateRef('itemRef')
const subMenuRef = useTemplateRef('subMenuRef')
const rootMenu = inject(rootMenuInjectionKey)!
const opened = computed(() => {
return rootMenu.openedMenus.includes(props.uniqueKey.at(-1)!)
})
const index = props.menu.path ?? rootMenu.getUseId(props.menu)
const opened = computed(() => rootMenu.openedMenus.includes(props.uniqueKey.at(-1)!))
const transitionEvent = computed(() => {
return rootMenu.isMenuPopup
? {
enter(el: HTMLElement) {
if (el.offsetHeight > window.innerHeight) {
el.style.height = `${window.innerHeight}px`
}
},
afterEnter: () => {},
beforeLeave: (el: HTMLElement) => {
el.style.maxHeight = `${el.offsetHeight}px`
el.style.overflow = 'hidden'
},
leave: (el: HTMLElement) => {
el.style.maxHeight = '0'
},
afterLeave(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
}
: CSS.supports('height', 'calc-size(auto, size)')
? {}
: {
enter(el: HTMLElement) {
requestAnimationFrame(() => {
el.dataset.height = el.offsetHeight.toString()
el.style.maxHeight = '0'
void el.offsetHeight
el.style.maxHeight = `${el.dataset.height}px`
el.style.overflow = 'hidden'
})
},
afterEnter(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
enterCancelled(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
beforeLeave(el: HTMLElement) {
el.style.maxHeight = `${el.offsetHeight}px`
el.style.overflow = 'hidden'
},
leave(el: HTMLElement) {
el.style.maxHeight = '0'
},
afterLeave(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
leaveCancelled(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
if (rootMenu.isMenuPopup) {
return {
enter(el: HTMLElement) {
if (el.offsetHeight > window.innerHeight) {
el.style.height = `${window.innerHeight}px`
}
},
afterEnter: () => {},
beforeLeave: (el: HTMLElement) => {
el.style.maxHeight = `${el.offsetHeight}px`
el.style.overflow = 'hidden'
},
leave: (el: HTMLElement) => {
el.style.maxHeight = '0'
},
afterLeave(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
}
}
if (CSS.supports('height', 'calc-size(auto, size)')) {
return {}
}
return {
enter(el: HTMLElement) {
requestAnimationFrame(() => {
el.dataset.height = el.offsetHeight.toString()
el.style.maxHeight = '0'
void el.offsetHeight
el.style.maxHeight = `${el.dataset.height}px`
el.style.overflow = 'hidden'
})
},
afterEnter(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
enterCancelled(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
beforeLeave(el: HTMLElement) {
el.style.maxHeight = `${el.offsetHeight}px`
el.style.overflow = 'hidden'
},
leave(el: HTMLElement) {
el.style.maxHeight = '0'
},
afterLeave(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
leaveCancelled(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
}
})
const transitionClass = computed(() => {
return rootMenu.isMenuPopup
? {
enterActiveClass: 'ease-in-out duration-300',
enterFromClass: 'opacity-0 translate-x-4',
enterToClass: 'opacity-100',
leaveActiveClass: 'ease-in-out duration-300',
leaveFromClass: 'opacity-100',
leaveToClass: 'opacity-0',
}
: {
enterActiveClass: 'ease-in-out duration-300',
enterFromClass: cn('opacity-0 translate-y-4 scale-95 blur-4', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
enterToClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveActiveClass: 'ease-in-out duration-300',
leaveFromClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveToClass: cn('opacity-0 translate-y-4 scale-95 blur-4', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
}
if (rootMenu.isMenuPopup) {
return {
enterActiveClass: 'ease-in-out duration-300',
enterFromClass: 'opacity-0 translate-x-4',
enterToClass: 'opacity-100',
leaveActiveClass: 'ease-in-out duration-300',
leaveFromClass: 'opacity-100',
leaveToClass: 'opacity-0',
}
}
return {
enterActiveClass: 'ease-in-out duration-300',
enterFromClass: cn('opacity-0 translate-y-4 scale-95 blur-4', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
enterToClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveActiveClass: 'ease-in-out duration-300',
leaveFromClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveToClass: cn('opacity-0 translate-y-4 scale-95 blur-4', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
}
})
const hasChildren = computed(() => {
return props.menu.children?.some((item: any) => item.meta?.menu !== false) ?? false
})
const hasChildren = computed(() => props.menu.children?.some((item: any) => item.meta?.menu !== false) ?? false)
function handleClick() {
if (rootMenu.isMenuPopup && hasChildren.value) {
if (
(rootMenu.isMenuPopup && hasChildren.value)
|| props.menu.meta?.link
) {
return
}
if (props.menu.meta?.link) {
return
}
if (hasChildren.value) {
rootMenu.handleSubMenuClick(index, props.uniqueKey)
}
else {
rootMenu.handleMenuItemClick(index)
}
requestAnimationFrame(() => {
if (hasChildren.value) {
rootMenu.handleSubMenuClick(index, props.uniqueKey)
}
else {
rootMenu.handleMenuItemClick(index)
}
})
}
let timeout: (() => void) | undefined
@@ -135,32 +137,44 @@ function handleMouseenter() {
if (hasChildren.value) {
rootMenu.openMenu(index, props.uniqueKey)
nextTick(() => {
const el = itemRef.value?.ref
const subMenuEl = subMenuRef.value?.$el
if (!el || !subMenuEl) {
return
}
let top = 0
let left = 0
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
top = el.getBoundingClientRect().top + el.scrollTop
left = el.getBoundingClientRect().left + el.getBoundingClientRect().width
if (top + subMenuEl.offsetHeight > window.innerHeight) {
top = Math.max(0, window.innerHeight - subMenuEl.offsetHeight)
requestAnimationFrame(() => {
const el = itemRef.value?.ref
const subMenuEl = subMenuRef.value?.$el
if (!el || !subMenuEl) {
return
}
}
else {
top = el.getBoundingClientRect().top + el.getBoundingClientRect().height
left = el.getBoundingClientRect().left
if (top + subMenuEl.offsetHeight > window.innerHeight) {
subMenuEl.style.height = `${window.innerHeight - top}px`
const rect = el.getBoundingClientRect()
const { top, left, width, height } = rect
let menuTop = 0
let menuLeft = 0
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
menuTop = top + el.scrollTop
menuLeft = left + width
// 处理边界情况
if (menuTop + subMenuEl.offsetHeight > window.innerHeight) {
menuTop = Math.max(0, window.innerHeight - subMenuEl.offsetHeight)
}
}
}
if (left + subMenuEl.offsetWidth > document.documentElement.clientWidth) {
left = el.getBoundingClientRect().left - el.getBoundingClientRect().width
}
subMenuEl.style.top = `${top}px`
subMenuEl.style.insetInlineStart = `${left}px`
else {
menuTop = top + height
menuLeft = left
// 处理边界情况
if (menuTop + subMenuEl.offsetHeight > window.innerHeight) {
subMenuEl.style.height = `${window.innerHeight - menuTop}px`
}
}
// 处理边界情况
if (menuLeft + subMenuEl.offsetWidth > document.documentElement.clientWidth) {
menuLeft = left - width
}
// 设置样式
Object.assign(subMenuEl.style, {
top: `${menuTop}px`,
insetInlineStart: `${menuLeft}px`,
willChange: 'transform',
transform: 'translateZ(0)',
})
})
})
}
else {
@@ -177,14 +191,16 @@ function handleMouseleave() {
rootMenu.mouseInMenu = []
timeout?.()
;({ stop: timeout } = useTimeoutFn(() => {
if (rootMenu.mouseInMenu.length === 0) {
rootMenu.closeMenu(props.uniqueKey)
}
else {
if (hasChildren.value) {
!rootMenu.mouseInMenu.includes(props.uniqueKey.at(-1)!) && rootMenu.closeMenu(props.uniqueKey.at(-1)!)
requestAnimationFrame(() => {
if (rootMenu.mouseInMenu.length === 0) {
rootMenu.closeMenu(props.uniqueKey)
}
}
else {
if (hasChildren.value) {
!rootMenu.mouseInMenu.includes(props.uniqueKey.at(-1)!) && rootMenu.closeMenu(props.uniqueKey.at(-1)!)
}
}
})
}, 300))
}
</script>
@@ -194,15 +210,15 @@ function handleMouseleave() {
<Teleport v-if="hasChildren" to="body" :disabled="!rootMenu.isMenuPopup">
<Transition v-bind="transitionClass" v-on="transitionEvent">
<FaScrollArea
v-if="opened" ref="subMenuRef" :scrollbar="false" :mask="rootMenu.isMenuPopup" :class="cn('sub-menu static h-[calc-size(auto,size)] rounded-lg', {
v-if="opened" ref="subMenuRef" :scrollbar="false" :mask="rootMenu.isMenuPopup" :class="cn('sub-menu static h-[calc-size(auto,size)] rounded-lg will-change-transform', {
'bg-[var(--g-sub-sidebar-bg)]': rootMenu.isMenuPopup,
'border shadow-xl fixed z-3000 w-[200px]': rootMenu.isMenuPopup,
'mx-1': rootMenu.isMenuPopup && (rootMenu.props.mode === 'vertical' || level !== 0),
'py-1': rootMenu.isMenuPopup,
})"
>
<template v-for="item in menu.children" :key="item.path ?? JSON.stringify(item)">
<SubMenu v-if="item.meta?.menu !== false" :unique-key="[...uniqueKey, item.path ?? JSON.stringify(item)]" :menu="item" :level="level + 1" />
<template v-for="item in menu.children" :key="item.path ?? rootMenu.getUseId(item)">
<SubMenu v-if="item.meta?.menu !== false" :unique-key="[...uniqueKey, item.path ?? rootMenu.getUseId(item)]" :menu="item" :level="level + 1" />
</template>
</FaScrollArea>
</Transition>
+1
View File
@@ -18,6 +18,7 @@ export interface MenuProps {
export interface MenuInjection {
props: MenuProps
getUseId: (obj: object) => string
items: Record<string, MenuItem>
subMenus: Record<string, MenuItem>
activeIndex: MenuProps['value']
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import Logo from '../Logo/index.vue'
import Menu from '../Menu/index.vue'
+36 -5
View File
@@ -4,8 +4,6 @@ import { useMagicKeys } from '@vueuse/core'
import hotkeys from 'hotkeys-js'
import { toast } from 'vue-sonner'
import { useSlots } from '@/slots'
import useSettingsStore from '@/store/modules/settings'
import useTabbarStore from '@/store/modules/tabbar'
defineOptions({
name: 'Tabbar',
@@ -85,16 +83,19 @@ function contextMenuItems(routeItem: Tabbar.recordRaw) {
[
{
label: '关闭其他标签页',
icon: 'i-mdi:close',
disabled: !tabbar.checkCloseOtherSide(routeItem.tabId),
handle: () => tabbar.closeOtherSide(routeItem.tabId),
},
{
label: '关闭左侧标签页',
icon: 'i-mdi:arrow-expand-left',
disabled: !tabbar.checkCloseLeftSide(routeItem.tabId),
handle: () => tabbar.closeLeftSide(routeItem.tabId),
},
{
label: '关闭右侧标签页',
icon: 'i-mdi:arrow-expand-right',
disabled: !tabbar.checkCloseRightSide(routeItem.tabId),
handle: () => tabbar.closeRightSide(routeItem.tabId),
},
@@ -102,6 +103,34 @@ function contextMenuItems(routeItem: Tabbar.recordRaw) {
]
}
const visibleTabIndex = ref<number[]>([])
function getVisibleTabs() {
const containerWidth = tabsRef.value?.ref?.$el.clientWidth ?? 0
const scrollLeft = tabsRef.value?.ref?.el?.viewportElement?.scrollLeft ?? 0
visibleTabIndex.value = []
if (tabRef.value) {
for (let i = 0; i < tabRef.value.length; i++) {
const tab = tabRef.value[i]
const tabLeft = tab.offsetLeft
const tabRight = tabLeft + tab.offsetWidth
// 检查标签页是否在可视区域内
if (tabLeft < scrollLeft + containerWidth && tabRight > scrollLeft) {
if (i >= 0 && i < tabbarStore.list.length) {
visibleTabIndex.value.push(i)
}
}
}
}
}
function getVisibleTabIndex(arrayIndex: number) {
return visibleTabIndex.value.findIndex(visibleTab => visibleTab === arrayIndex) ?? -1
}
watch(() => keys.alt, (val) => {
if (val) {
getVisibleTabs()
}
})
onMounted(() => {
hotkeys('alt+left,alt+right,alt+w,alt+1,alt+2,alt+3,alt+4,alt+5,alt+6,alt+7,alt+8,alt+9,alt+0', (e, handle) => {
if (settingsStore.settings.tabbar.enable && settingsStore.settings.tabbar.enableHotkeys) {
@@ -137,7 +166,9 @@ onMounted(() => {
case 'alt+9':
{
const number = Number(handle.key.split('+')[1])
tabbarStore.list[number - 1]?.fullPath && router.push(tabbarStore.list[number - 1].fullPath)
if (visibleTabIndex.value[number - 1] !== undefined) {
router.push(tabbarStore.list[visibleTabIndex.value[number - 1]].fullPath)
}
break
}
// 切换到最后一个标签页
@@ -178,8 +209,8 @@ onUnmounted(() => {
<div v-if="tabbarStore.list.length > 1" class="action-icon" @click.stop="tabbar.closeById(element.tabId)">
<FaIcon name="i-ri:close-fill" />
</div>
<div v-show="keys.alt && index < 9" class="hotkey-number">
{{ index + 1 }}
<div v-show="keys.alt && getVisibleTabIndex(index) >= 0 && getVisibleTabIndex(index) < 9" class="hotkey-number">
{{ getVisibleTabIndex(index) + 1 }}
</div>
</div>
<template #content>
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { compile } from 'path-to-regexp'
import useSettingsStore from '@/store/modules/settings'
import Breadcrumb from '../../../Breadcrumb/index.vue'
import BreadcrumbItem from '../../../Breadcrumb/item.vue'
@@ -1,6 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'ColorScheme',
})
@@ -8,28 +6,40 @@ defineOptions({
const settingsStore = useSettingsStore()
function toggleColorScheme(event: MouseEvent) {
const { startViewTransition } = useViewTransition(() => {
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
settingsStore.currentColorScheme && settingsStore.setColorScheme(settingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
return
}
const target = event.target as HTMLElement
const { left, top, width, height } = target.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)
const ratioX = (100 * x) / innerWidth
const ratioY = (100 * y) / innerHeight
const referR = Math.hypot(innerWidth, innerHeight) / Math.SQRT2
const ratioR = (100 * endRadius) / referR
const transition = document.startViewTransition(async () => {
settingsStore.currentColorScheme && settingsStore.setColorScheme(settingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
await nextTick()
})
startViewTransition()?.ready.then(() => {
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
`circle(0% at ${ratioX}% ${ratioY}%)`,
`circle(${ratioR}% at ${ratioX}% ${ratioY}%)`,
]
document.documentElement.animate(
{
clipPath: settingsStore.settings.app.colorScheme !== 'dark' ? clipPath : clipPath.reverse(),
clipPath: settingsStore.currentColorScheme === 'light' ? clipPath : [...clipPath].reverse(),
},
{
duration: 300,
easing: 'ease-out',
pseudoElement: settingsStore.settings.app.colorScheme !== 'dark' ? '::view-transition-new(root)' : '::view-transition-old(root)',
duration: 500,
easing: 'ease-in-out',
fill: 'both',
pseudoElement: settingsStore.currentColorScheme === 'light' ? '::view-transition-new(root)' : '::view-transition-old(root)',
},
)
})
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useFullscreen } from '@vueuse/core'
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'Fullscreen',
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import Search from './search.vue'
defineOptions({
@@ -4,9 +4,6 @@ import { useFocus } from '@vueuse/core'
import hotkeys from 'hotkeys-js'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import useMenuStore from '@/store/modules/menu'
import useRouteStore from '@/store/modules/route'
import useSettingsStore from '@/store/modules/settings'
import { resolveRoutePath } from '@/utils'
defineOptions({
@@ -1,6 +1,5 @@
<script setup lang="ts">
import hotkeys from 'hotkeys-js'
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'PageReload',
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useSettingsStore from '@/store/modules/settings'
import Breadcrumb from './Breadcrumb/index.vue'
defineOptions({
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useSettingsStore from '@/store/modules/settings'
import ColorScheme from './ColorScheme/index.vue'
import Fullscreen from './Fullscreen/index.vue'
import NavSearch from './NavSearch/index.vue'
-1
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import useSettingsStore from '@/store/modules/settings'
import Tabbar from './Tabbar/index.vue'
import Toolbar from './Toolbar/index.vue'
-3
View File
@@ -1,8 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useKeepAliveStore from '@/store/modules/keepAlive'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import eventBus from '@/utils/eventBus'
import AppSetting from './components/AppSetting/index.vue'
import Header from './components/Header/index.vue'
+2 -2
View File
@@ -8,13 +8,13 @@ import App from './App.vue'
import router from './router'
import pinia from './store'
import uiProvider from './ui/provider'
import '@/utils/systemCopyright'
// 加载 svg 图标
import 'virtual:svg-icons-register'
import 'virtual:uno.css'
// UnoCSS
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno.css'
// 全局样式
import '@/assets/styles/globals.css'
+24 -2
View File
@@ -1,7 +1,5 @@
import type { RouteLocationRaw, Router } from 'vue-router'
import pinia from '@/store'
import useSettingsStore from '@/store/modules/settings'
import useTabbarStore from '@/store/modules/tabbar'
function getId(router: Router) {
return router.currentRoute.value.fullPath
@@ -57,8 +55,32 @@ function extendReplace(router: Router) {
}
}
declare module 'vue-router' {
interface Router {
/**
* 本方法为框架扩展语法,等同于 `push` 方法,并且同时会关闭当前标签页
*/
close: (to: RouteLocationRaw) => Promise<NavigationFailure | void | undefined>
}
}
function extendClose(router: Router) {
router.close = function (to: RouteLocationRaw) {
const currentRoute = router.currentRoute.value
const tabId = getId(router)
return router.push(to).then(() => {
const settingsStore = useSettingsStore(pinia)
if (settingsStore.settings.tabbar.enable && currentRoute.meta.tabMerge !== 'activeMenu') {
const tabbarStore = useTabbarStore(pinia)
tabbarStore.remove(tabId)
}
})
}
}
export default function setupExtensions(router: Router) {
extendPush(router)
extendGo(router)
extendReplace(router)
extendClose(router)
}
+40 -50
View File
@@ -1,10 +1,5 @@
import type { Router, RouteRecordRaw } from 'vue-router'
import { useNProgress } from '@vueuse/integrations/useNProgress'
import useKeepAliveStore from '@/store/modules/keepAlive'
import useMenuStore from '@/store/modules/menu'
import useRouteStore from '@/store/modules/route'
import useSettingsStore from '@/store/modules/settings'
import useUserStore from '@/store/modules/user'
import { asyncRoutes, asyncRoutesByFilesystem } from './routes'
import '@/assets/styles/nprogress.css'
@@ -165,54 +160,49 @@ function setupTitle(router: Router) {
// 页面缓存
function setupKeepAlive(router: Router) {
router.afterEach((to, from) => {
router.afterEach(async (to, from) => {
const keepAliveStore = useKeepAliveStore()
// 判断当前页面是否开启缓存,如果开启,则将当前页面的 name 信息存入 keep-alive 全局状态
if (to.meta.cache) {
const componentName = to.matched.at(-1)?.components?.default.name
if (componentName) {
keepAliveStore.add(componentName)
}
else {
// turbo-console-disable-next-line
console.warn('[Fantastic-admin] 该页面组件未设置组件名,会导致缓存失效,请检查')
}
}
// 判断离开页面是否开启缓存,如果开启,则根据缓存规则判断是否需要清空 keep-alive 全局状态里离开页面的 name 信息
if (from.meta.cache) {
const componentName = from.matched.at(-1)?.components?.default.name
if (componentName) {
// 通过 meta.cache 判断针对哪些页面进行缓存
switch (typeof from.meta.cache) {
case 'string':
if (from.meta.cache !== to.name) {
keepAliveStore.remove(componentName)
}
break
case 'object':
if (!from.meta.cache.includes(to.name as string)) {
keepAliveStore.remove(componentName)
}
break
}
// 通过 meta.noCache 判断针对哪些页面不需要进行缓存
if (from.meta.noCache) {
switch (typeof from.meta.noCache) {
case 'string':
if (from.meta.noCache === to.name) {
keepAliveStore.remove(componentName)
}
break
case 'object':
if (from.meta.noCache.includes(to.name as string)) {
keepAliveStore.remove(componentName)
}
break
if (to.fullPath !== from.fullPath) {
if (to.meta.cache) {
const componentName = to.matched.at(-1)?.components?.default.name
if (componentName) {
// 缓存当前页面前,先判断是否需要进行清除缓存,判断依据:
// 1. 如果 to.meta.cache 为 boolean 类型,并且不为 true,则需要清除缓存
// 2. 如果 to.meta.cache 为 string 类型,并且与 from.name 不一致,则需要清除缓存
// 3. 如果 to.meta.cache 为 array 类型,并且不包含 from.name,则需要清除缓存
// 4. 如果 to.meta.noCache 为 string 类型,并且与 from.name 一致,则需要清除缓存
// 5. 如果 to.meta.noCache 为 array 类型,并且包含 from.name,则需要清除缓存
// 6. 如果是刷新页面,则需要清除缓存
let shouldClearCache = false
if (typeof to.meta.cache === 'boolean') {
shouldClearCache = !to.meta.cache
}
else if (typeof to.meta.cache === 'string') {
shouldClearCache = to.meta.cache !== from.name
}
else if (Array.isArray(to.meta.cache)) {
shouldClearCache = !to.meta.cache.includes(from.name as string)
}
if (to.meta.noCache) {
if (typeof to.meta.noCache === 'string') {
shouldClearCache = to.meta.noCache === from.name
}
else if (Array.isArray(to.meta.noCache)) {
shouldClearCache = to.meta.noCache.includes(from.name as string)
}
}
if (from.name === 'reload') {
shouldClearCache = true
}
if (shouldClearCache) {
keepAliveStore.remove(componentName)
await nextTick()
}
keepAliveStore.add(componentName)
}
// 如果进入的是 reload 页面,则也将离开页面的缓存清空
if (to.name === 'reload') {
keepAliveStore.remove(componentName)
else {
// turbo-console-disable-next-line
console.warn('[Fantastic-admin] 该页面组件未设置组件名,会导致缓存失效,请检查')
}
}
}
-1
View File
@@ -1,7 +1,6 @@
import { loadingFadeOut } from 'virtual:app-loading'
import { createRouter, createWebHashHistory } from 'vue-router'
import pinia from '@/store'
import useSettingsStore from '@/store/modules/settings'
import setupExtensions from './extensions'
import setupGuards from './guards'
// 路由相关数据
-1
View File
@@ -2,7 +2,6 @@ import type { Route } from '#/global'
import type { RouteRecordRaw } from 'vue-router'
import generatedRoutes from 'virtual:generated-pages'
import { setupLayouts } from 'virtual:meta-layouts'
import useSettingsStore from '@/store/modules/settings'
import MultilevelMenuExample from './modules/multilevel.menu.example'
// 固定路由(默认路由)
+7 -7
View File
@@ -1,12 +1,12 @@
import { pascalCase } from 'scule'
type Slots =
'header-start' | 'header-after-logo' | 'header-after-menu' | 'header-end' |
'main-sidebar-top' | 'main-sidebar-after-logo' | 'main-sidebar-after-menu' | 'main-sidebar-bottom' |
'sub-sidebar-top' | 'sub-sidebar-after-logo' | 'sub-sidebar-after-menu' | 'sub-sidebar-bottom' |
'tabbar-start' | 'tabbar-end' |
'toolbar-start' | 'toolbar-end' |
'free-position'
type Slots
= 'header-start' | 'header-after-logo' | 'header-after-menu' | 'header-end'
| 'main-sidebar-top' | 'main-sidebar-after-logo' | 'main-sidebar-after-menu' | 'main-sidebar-bottom'
| 'sub-sidebar-top' | 'sub-sidebar-after-logo' | 'sub-sidebar-after-menu' | 'sub-sidebar-bottom'
| 'tabbar-start' | 'tabbar-end'
| 'toolbar-start' | 'toolbar-end'
| 'free-position'
function tryLoadComponent(name: Slots) {
const componentMap = import.meta.glob('./*/index.vue', { eager: true })
Executable → Regular
View File
+1 -3
View File
@@ -1,4 +1,4 @@
const useKeepAliveStore = defineStore(
export const useKeepAliveStore = defineStore(
// 唯一ID
'keepAlive',
() => {
@@ -38,5 +38,3 @@ const useKeepAliveStore = defineStore(
}
},
)
export default useKeepAliveStore
+7 -36
View File
@@ -4,16 +4,12 @@ import { cloneDeep } from 'es-toolkit'
import apiApp from '@/api/modules/app'
import menu from '@/menu'
import { resolveRoutePath } from '@/utils'
import useRouteStore from './route'
import useSettingsStore from './settings'
import useUserStore from './user'
const useMenuStore = defineStore(
export const useMenuStore = defineStore(
// 唯一ID
'menu',
() => {
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const routeStore = useRouteStore()
const filesystemMenusRaw = ref<Menu.recordMainRaw[]>([])
@@ -78,10 +74,7 @@ const useMenuStore = defineStore(
else {
returnMenus = filesystemMenusRaw.value
}
// 如果权限功能开启,则需要对导航数据进行筛选过滤
if (settingsStore.settings.app.enablePermission) {
returnMenus = filterAsyncMenus(returnMenus, userStore.permissions)
}
returnMenus = filterAsyncMenus(returnMenus)
return returnMenus
})
// 次导航数据
@@ -100,7 +93,7 @@ const useMenuStore = defineStore(
})
function getDeepestPath(menu: Menu.recordRaw, rootPath = '') {
let retnPath = ''
if (menu.children) {
if (menu.children?.some(item => item.meta?.menu !== false)) {
const item = menu.children.find(item => item.meta?.menu !== false)
if (item) {
retnPath = getDeepestPath(item, resolveRoutePath(rootPath, menu.path))
@@ -155,35 +148,15 @@ const useMenuStore = defineStore(
return defaultOpenedPaths
}
// 判断是否有权限
function hasPermission(permissions: string[], menu: Menu.recordMainRaw | Menu.recordRaw) {
let isAuth = false
if (menu.meta?.auth) {
isAuth = permissions.some((auth) => {
if (typeof menu.meta?.auth === 'string') {
return menu.meta.auth !== '' ? menu.meta.auth === auth : true
}
else if (typeof menu.meta?.auth === 'object') {
return menu.meta.auth.length > 0 ? menu.meta.auth.includes(auth) : true
}
else {
return false
}
})
}
else {
isAuth = true
}
return isAuth
}
const auth = useAuth()
// 根据权限过滤导航
function filterAsyncMenus<T extends Menu.recordMainRaw[] | Menu.recordRaw[]>(menus: T, permissions: string[]): T {
function filterAsyncMenus<T extends Menu.recordMainRaw[] | Menu.recordRaw[]>(menus: T): T {
const res: any = []
menus.forEach((menu) => {
if (hasPermission(permissions, menu)) {
if (auth.auth(menu.meta?.auth ?? '')) {
const tmpMenu = cloneDeep(menu)
if (tmpMenu.children && tmpMenu.children.length > 0) {
tmpMenu.children = filterAsyncMenus(tmpMenu.children, permissions) as Menu.recordRaw[]
tmpMenu.children = filterAsyncMenus(tmpMenu.children) as Menu.recordRaw[]
tmpMenu.children.length > 0 && res.push(tmpMenu)
}
else {
@@ -242,5 +215,3 @@ const useMenuStore = defineStore(
}
},
)
export default useMenuStore
+2 -5
View File
@@ -4,9 +4,8 @@ import { cloneDeep } from 'es-toolkit'
import { createRouterMatcher } from 'vue-router'
import apiApp from '@/api/modules/app'
import { systemRoutes as systemRoutesRaw } from '@/router/routes'
import useSettingsStore from './settings'
const useRouteStore = defineStore(
export const useRouteStore = defineStore(
// 唯一ID
'route',
() => {
@@ -131,7 +130,7 @@ const useRouteStore = defineStore(
})
routesMatcher.value = createRouterMatcher(routes, {})
isGenerate.value = true
}).catch(() => {})
})
}
// 生成路由(文件系统生成)
function generateRoutesAtFilesystem(asyncRoutes: RouteRecordRaw[]) {
@@ -169,5 +168,3 @@ const useRouteStore = defineStore(
}
},
)
export default useRouteStore
+1 -3
View File
@@ -4,7 +4,7 @@ import { cloneDeep } from 'es-toolkit'
import settingsDefault from '@/settings'
import { merge } from '@/utils/object'
const useSettingsStore = defineStore(
export const useSettingsStore = defineStore(
// 唯一ID
'settings',
() => {
@@ -172,5 +172,3 @@ const useSettingsStore = defineStore(
}
},
)
export default useSettingsStore
+2 -5
View File
@@ -1,8 +1,7 @@
import type { Tabbar } from '#/global'
import type { RouteLocationNormalized } from 'vue-router'
import useKeepAliveStore from './keepAlive'
const useTabbarStore = defineStore(
export const useTabbarStore = defineStore(
// 唯一ID
'tabbar',
() => {
@@ -20,7 +19,7 @@ const useTabbarStore = defineStore(
}
})
const meta = route.matched.at(-1)?.meta
const tabId = route.fullPath
const tabId = route.path
if (route.name !== 'reload') {
// 记录查找到的标签页
const findTab = list.value.find((item) => {
@@ -159,5 +158,3 @@ const useTabbarStore = defineStore(
}
},
)
export default useTabbarStore
+1 -7
View File
@@ -1,11 +1,7 @@
import apiUser from '@/api/modules/user'
import router from '@/router'
import useMenuStore from './menu'
import useRouteStore from './route'
import useSettingsStore from './settings'
import useTabbarStore from './tabbar'
const useUserStore = defineStore(
export const useUserStore = defineStore(
// 唯一ID
'user',
() => {
@@ -109,5 +105,3 @@ const useUserStore = defineStore(
}
},
)
export default useUserStore
Vendored Executable → Regular
+9 -2
View File
@@ -19,12 +19,14 @@ declare global {
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
@@ -71,16 +73,21 @@ declare global {
const useCssVars: typeof import('vue')['useCssVars']
const useGlobalProperties: typeof import('../utils/composables/useGlobalProperties')['default']
const useId: typeof import('vue')['useId']
const useKeepAliveStore: typeof import('../store/modules/keepAlive')['useKeepAliveStore']
const useLink: typeof import('vue-router')['useLink']
const useMainPage: typeof import('../utils/composables/useMainPage')['default']
const useMenu: typeof import('../utils/composables/useMenu')['default']
const useMenuStore: typeof import('../store/modules/menu')['useMenuStore']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouteStore: typeof import('../store/modules/route')['useRouteStore']
const useRouter: typeof import('vue-router')['useRouter']
const useSettingsStore: typeof import('../store/modules/settings')['useSettingsStore']
const useSlots: typeof import('vue')['useSlots']
const useTabbar: typeof import('../utils/composables/useTabbar')['default']
const useTabbarStore: typeof import('../store/modules/tabbar')['useTabbarStore']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useViewTransition: typeof import('../utils/composables/useViewTransition')['default']
const useUserStore: typeof import('../store/modules/user')['useUserStore']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
@@ -89,6 +96,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+6 -4
View File
@@ -13,16 +13,21 @@ declare module 'vue' {
FaAvatar: typeof import('./../ui/components/FaAvatar/index.vue')['default']
FaBackToTop: typeof import('./../ui/components/FaBackToTop/index.vue')['default']
FaButton: typeof import('./../ui/components/FaButton/index.vue')['default']
FaButtonGroup: typeof import('./../ui/components/FaButtonGroup/index.vue')['default']
FaCard: typeof import('./../ui/components/FaCard/index.vue')['default']
FaCheckbox: typeof import('./../ui/components/FaCheckbox/index.vue')['default']
FaCollapsible: typeof import('./../ui/components/FaCollapsible/index.vue')['default']
FaContextMenu: typeof import('./../ui/components/FaContextMenu/index.vue')['default']
FaCopyright: typeof import('./../ui/components/FaCopyright/index.vue')['default']
FaDivider: typeof import('./../ui/components/FaDivider/index.vue')['default']
FaDrawer: typeof import('./../ui/components/FaDrawer/index.vue')['default']
FaDropdown: typeof import('./../ui/components/FaDropdown/index.vue')['default']
FaFileUpload: typeof import('./../ui/components/FaFileUpload/index.vue')['default']
FaFixedActionBar: typeof import('./../ui/components/FaFixedActionBar/index.vue')['default']
FaHoverCard: typeof import('./../ui/components/FaHoverCard/index.vue')['default']
FaIcon: typeof import('./../ui/components/FaIcon/index.vue')['default']
FaImagePreview: typeof import('./../ui/components/FaImagePreview/index.vue')['default']
FaImageUpload: typeof import('./../ui/components/FaImageUpload/index.vue')['default']
FaInput: typeof import('./../ui/components/FaInput/index.vue')['default']
FaKbd: typeof import('./../ui/components/FaKbd/index.vue')['default']
FaModal: typeof import('./../ui/components/FaModal/index.vue')['default']
@@ -42,12 +47,9 @@ declare module 'vue' {
FaSwitch: typeof import('./../ui/components/FaSwitch/index.vue')['default']
FaSystemInfo: typeof import('./../ui/components/FaSystemInfo/index.vue')['default']
FaTabs: typeof import('./../ui/components/FaTabs/index.vue')['default']
FaTextarea: typeof import('./../ui/components/FaTextarea/index.vue')['default']
FaToast: typeof import('./../ui/components/FaToast/index.vue')['default']
FaTooltip: typeof import('./../ui/components/FaTooltip/index.vue')['default']
FileUpload: typeof import('./../components/FileUpload/index.vue')['default']
ImagePreview: typeof import('./../components/ImagePreview/index.vue')['default']
ImagesUpload: typeof import('./../components/ImagesUpload/index.vue')['default']
ImageUpload: typeof import('./../components/ImageUpload/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { cn } from '@/utils'
defineOptions({
name: 'FaButtonGroup',
})
const props = defineProps<{
vertical?: boolean
}>()
</script>
<template>
<div
:class="cn('inline-flex gap-[1px] items-stretch', {
'horizontal-group flex-row': !props.vertical,
'vertical-group flex-col': props.vertical,
})"
>
<slot />
</div>
</template>
<style scoped>
.horizontal-group {
:deep(> button) {
&:first-child:not(:last-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
&:last-child:not(:first-child) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
&:not(:first-child, :last-child) {
border-radius: 0;
}
}
}
.vertical-group {
:deep(> button) {
&:first-child:not(:last-child) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
&:last-child:not(:first-child) {
border-start-start-radius: 0;
border-start-end-radius: 0;
}
&:not(:first-child, :last-child) {
border-radius: 0;
}
}
}
</style>
@@ -1,19 +1,15 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Check } from 'lucide-vue-next'
import { reactiveOmit } from '@vueuse/core'
import { Check, Minus } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -21,11 +17,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<CheckboxRoot
v-bind="forwarded"
:class="cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', props.class)"
:class="cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary bg-transparent ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground', props.class)"
>
<CheckboxIndicator class="h-full w-full flex items-center justify-center text-current">
<slot>
<Check class="h-4 w-4" />
<Minus v-if="props.modelValue === 'indeterminate'" class="h-4 w-4" />
<Check v-if="props.modelValue === true" class="h-4 w-4" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
+8 -4
View File
@@ -1,23 +1,27 @@
<script setup lang="ts">
import type { CheckboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { Checkbox } from './checkbox'
defineOptions({
name: 'FaCheckbox',
})
defineProps<{
const props = defineProps<{
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const value = defineModel<boolean>()
const value = defineModel<CheckboxRootProps['modelValue']>()
const id = useId()
</script>
<template>
<div class="flex-center-start gap-2">
<div :class="cn('flex-center-start gap-2', props.class)">
<Checkbox :id v-model="value" :disabled />
<label :for="id" class="cursor-pointer text-sm">
<label :for="id" class="cursor-pointer text-sm empty:hidden">
<slot />
</label>
</div>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'reka-ui'
import { CollapsibleRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot v-slot="{ open }" v-bind="forwarded">
<slot :open="open" />
</CollapsibleRoot>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { CollapsibleContentProps } from 'reka-ui'
import { CollapsibleContent } from 'reka-ui'
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent v-bind="props" class="CollapsibleContent">
<slot />
</CollapsibleContent>
</template>
<style scoped>
.CollapsibleContent {
overflow: hidden;
}
.CollapsibleContent[data-state="open"] {
animation: collapsible-down 300ms ease-out;
}
.CollapsibleContent[data-state="closed"] {
animation: collapsible-up 300ms ease-out;
}
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(--reka-collapsible-content-height);
}
}
@keyframes collapsible-up {
from {
height: var(--reka-collapsible-content-height);
}
to {
height: 0;
}
}
</style>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { CollapsibleTriggerProps } from 'reka-ui'
import { CollapsibleTrigger } from 'reka-ui'
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger v-bind="props">
<slot />
</CollapsibleTrigger>
</template>
@@ -0,0 +1,3 @@
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleContent } from './CollapsibleContent.vue'
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible'
defineOptions({
name: 'FaCollapsible',
})
const open = defineModel<boolean>('modelValue', {
default: false,
})
const slots = useSlots()
</script>
<template>
<Collapsible v-model:open="open">
<CollapsibleTrigger v-if="!!slots.trigger">
<slot name="trigger" :open />
</CollapsibleTrigger>
<CollapsibleContent>
<slot />
</CollapsibleContent>
</Collapsible>
</template>
@@ -1,23 +1,19 @@
<script setup lang="ts">
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Check } from 'lucide-vue-next'
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,22 +1,18 @@
<script setup lang="ts">
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,21 +1,17 @@
<script setup lang="ts">
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuItem,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuItemProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,17 +1,13 @@
<script setup lang="ts">
import type { ContextMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuLabel } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
@@ -1,23 +1,19 @@
<script setup lang="ts">
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Circle } from 'lucide-vue-next'
import {
ContextMenuItemIndicator,
ContextMenuRadioItem,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuRadioItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,19 +1,15 @@
<script setup lang="ts">
import type { ContextMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuSeparator,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
@@ -1,21 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,21 +1,17 @@
<script setup lang="ts">
import type { ContextMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRight } from 'lucide-vue-next'
import {
ContextMenuSubTrigger,
useForwardProps,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
-2
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'FaCopyright',
})
+137
View File
@@ -0,0 +1,137 @@
import type { Component, HTMLAttributes } from 'vue'
import { createVNode, isVNode, render } from 'vue'
import Drawer from './index.vue'
export interface DrawerProps {
id?: string
modelValue?: boolean
zIndex?: number
side?: 'top' | 'bottom' | 'left' | 'right'
title?: string
description?: string
loading?: boolean
closable?: boolean
centered?: boolean
bordered?: boolean
overlay?: boolean
overlayBlur?: boolean
showConfirmButton?: boolean
showCancelButton?: boolean
confirmButtonText?: string
cancelButtonText?: string
confirmButtonDisabled?: boolean
confirmButtonLoading?: boolean
beforeClose?: (
action: 'confirm' | 'cancel' | 'close',
done: () => void
) => void
header?: boolean
footer?: boolean
closeOnClickOverlay?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
contentClass?: HTMLAttributes['class']
headerClass?: HTMLAttributes['class']
footerClass?: HTMLAttributes['class']
}
export interface DrawerEmits {
'update:modelValue': [value: boolean]
'open': []
'opened': []
'close': []
'closed': []
'confirm': []
'cancel': []
}
type BaseOptions = Omit<DrawerProps, 'modelValue'> & {
content?: Component | VNode | string
onOpen?: () => void
onOpened?: () => void
onClose?: () => void
onClosed?: () => void
onConfirm?: () => void
onCancel?: () => void
}
export function useFaDrawer() {
function create(initialOptions: BaseOptions) {
const container = document.createElement('div')
const visible = ref(false)
const options = reactive({ ...initialOptions })
const instance = getCurrentInstance()
let vnode: VNode | null = null
const updateVNode = () => {
vnode = createVNode(Drawer, Object.assign({
'id': instance && instance.uid ? `FaDrawer-${instance.uid}` : undefined,
'modelValue': visible.value,
'onUpdate:modelValue': (val: boolean) => {
visible.value = val
},
...options,
}), {
default: () => {
if (typeof options.content === 'string') {
return options.content
}
else if (isVNode(options.content)) {
return options.content
}
else if (options.content) {
return h(options.content)
}
return null
},
})
// 继承主应用的上下文
if (instance && instance.appContext) {
vnode.appContext = instance.appContext
}
render(vnode, container)
}
// 监听 visible 和 options 变化,自动重新渲染
watch([visible, options], () => {
updateVNode()
}, {
immediate: true,
deep: true,
})
// 挂载到当前实例
instance?.proxy?.$el?.appendChild(container)
// 监听组件卸载,自动清理
if (instance) {
onUnmounted(() => {
if (vnode) {
render(null, container)
vnode = null
}
if (container.parentNode) {
container.parentNode.removeChild(container)
}
})
}
const open = () => {
visible.value = true
}
const close = () => {
visible.value = false
}
const update = (newOptions: BaseOptions) => {
Object.assign(options, newOptions)
}
return {
open,
close,
update,
}
}
return {
create,
}
}
+102 -55
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { DrawerEmits, DrawerProps } from '.'
import { VisuallyHidden } from 'reka-ui'
import { cn } from '@/utils'
import {
Sheet,
@@ -15,33 +16,10 @@ defineOptions({
})
const props = withDefaults(
defineProps<{
modelValue?: boolean
side?: 'top' | 'bottom' | 'left' | 'right'
title: string
description?: string
loading?: boolean
closable?: boolean
centered?: boolean
bordered?: boolean
overlay?: boolean
overlayBlur?: boolean
showConfirmButton?: boolean
showCancelButton?: boolean
confirmButtonText?: string
cancelButtonText?: string
confirmButtonDisabled?: boolean
confirmButtonLoading?: boolean
header?: boolean
footer?: boolean
closeOnClickOverlay?: boolean
closeOnPressEscape?: boolean
contentClass?: HTMLAttributes['class']
headerClass?: HTMLAttributes['class']
footerClass?: HTMLAttributes['class']
}>(),
defineProps<DrawerProps>(),
{
modelValue: false,
zIndex: 2000,
side: 'right',
loading: false,
closable: true,
@@ -59,47 +37,102 @@ const props = withDefaults(
footer: true,
closeOnClickOverlay: true,
closeOnPressEscape: true,
destroyOnClose: true,
},
)
const emits = defineEmits<{
'update:modelValue': [value: boolean]
'open': []
'opened': []
'close': []
'closed': []
'confirm': []
'cancel': []
const emits = defineEmits<DrawerEmits>()
const slots = defineSlots<{
header?: () => VNode
default?: () => VNode
footer?: () => VNode
}>()
const id = useId()
provide('DrawerId', id)
const drawerId = shallowRef(props.id ?? useId())
const isOpen = ref(props.modelValue)
watch(() => props.modelValue, (newValue) => {
isOpen.value = newValue
})
function updateOpen(value: boolean) {
isOpen.value = value
emits('update:modelValue', value)
if (value) {
const hasOpened = ref(false)
const isClosed = ref(true)
watch(isOpen, (val) => {
emits('update:modelValue', val)
if (val) {
emits('open')
}
else {
emits('close')
}
isClosed.value = false
if (val && !hasOpened.value) {
hasOpened.value = true
}
}, {
immediate: true,
})
const forceMount = computed(() => !props.destroyOnClose && hasOpened.value)
async function updateOpen(value: boolean) {
if (value) {
isOpen.value = value
emits('open')
}
else {
if (props.beforeClose) {
await props.beforeClose(
'close',
() => {
isOpen.value = value
emits('close')
},
)
}
else {
isOpen.value = value
emits('close')
}
}
}
function onConfirm() {
updateOpen(false)
emits('confirm')
const isConfirmButtonLoading = ref(false)
async function onConfirm() {
if (props.beforeClose) {
isConfirmButtonLoading.value = true
await props.beforeClose(
'confirm',
() => {
isOpen.value = false
emits('confirm')
},
)
isConfirmButtonLoading.value = false
}
else {
isOpen.value = false
emits('confirm')
}
}
function onCancel() {
updateOpen(false)
emits('cancel')
async function onCancel() {
if (props.beforeClose) {
await props.beforeClose(
'cancel',
() => {
isOpen.value = false
emits('cancel')
},
)
}
else {
isOpen.value = false
emits('cancel')
}
}
function handleFocusOutside(e: Event) {
@@ -108,7 +141,7 @@ function handleFocusOutside(e: Event) {
}
function handleClickOutside(e: Event) {
if (!props.closeOnClickOverlay || (e.target as HTMLElement).dataset.drawerId !== id) {
if (!props.closeOnClickOverlay || (e.target as HTMLElement).dataset.drawerId !== drawerId.value) {
e.preventDefault()
e.stopPropagation()
}
@@ -127,6 +160,7 @@ function handleAnimationEnd() {
}
else {
emits('closed')
isClosed.value = true
}
}
</script>
@@ -134,12 +168,17 @@ function handleAnimationEnd() {
<template>
<Sheet :modal="false" :open="isOpen" @update:open="updateOpen">
<SheetContent
:closable="props.closable"
:drawer-id="drawerId"
:open="isOpen"
:z-index="props.zIndex"
:closable="props.closable"
:overlay="props.overlay"
:overlay-blur="props.overlayBlur"
:class="cn('w-full flex flex-col gap-0 p-0', props.contentClass)"
:class="cn('w-full flex flex-col gap-0 p-0', props.contentClass, {
hidden: isClosed,
})"
:side="props.side"
:force-mount="forceMount"
@open-auto-focus="handleFocusOutside"
@close-auto-focus="handleFocusOutside"
@focus-outside="handleFocusOutside"
@@ -153,6 +192,10 @@ function handleAnimationEnd() {
'border-b': props.bordered,
})"
>
<VisuallyHidden v-if="!!slots.header">
<SheetTitle />
<SheetDescription />
</VisuallyHidden>
<slot name="header">
<SheetTitle :class="{ 'text-center': props.centered }">
{{ title }}
@@ -162,18 +205,22 @@ function handleAnimationEnd() {
</SheetDescription>
</slot>
</SheetHeader>
<VisuallyHidden v-else>
<SheetTitle />
<SheetDescription />
</VisuallyHidden>
<div class="m-0 flex-1 of-y-hidden">
<FaScrollArea class="h-full">
<FaScrollArea v-if="!!slots.default" class="h-full">
<div class="p-4">
<slot />
</div>
<div v-show="props.loading" class="absolute inset-0 z-1000 size-full flex-center bg-popover/75">
<FaIcon name="i-line-md:loading-twotone-loop" class="size-10" />
</div>
</FaScrollArea>
<div v-show="props.loading" class="absolute inset-0 z-1000 size-full flex-center bg-popover/75">
<FaIcon name="i-line-md:loading-twotone-loop" class="size-10" />
</div>
</div>
<SheetFooter
v-if="footer" :class="cn('p-2 gap-y-2', props.footerClass, {
v-if="footer" :class="cn('p-3 gap-y-2', props.footerClass, {
'sm:justify-center': props.centered,
'border-t': props.bordered,
})"
@@ -2,7 +2,7 @@
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { SheetVariants } from '.'
import { useScrollLock } from '@vueuse/core'
import { reactiveOmit, useScrollLock } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
@@ -10,17 +10,18 @@ import {
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
import { sheetVariants } from '.'
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class']
drawerId: string
open?: boolean
zIndex?: number
side?: SheetVariants['side']
closable?: boolean
overlay?: boolean
overlayBlur?: boolean
class?: HTMLAttributes['class']
}
defineOptions({
@@ -33,11 +34,7 @@ const emits = defineEmits<DialogContentEmits & {
animationEnd: []
}>()
const delegatedProps = computed(() => {
const { class: _, side, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class', 'side')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
@@ -51,8 +48,6 @@ watch(showOverlay, (val) => {
isLocked.value = false
}
})
const id = inject('DrawerId')
</script>
<template>
@@ -70,14 +65,20 @@ const id = inject('DrawerId')
>
<div
v-if="showOverlay"
:data-drawer-id="id"
:class="cn('fixed inset-0 z-2000 data-[state=closed]:animate-out data-[state=open]:animate-in bg-black/50 data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0', {
:data-drawer-id="props.drawerId"
:class="cn('fixed inset-0 data-[state=closed]:animate-out data-[state=open]:animate-in bg-black/50 data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0', {
'backdrop-blur-sm': props.overlayBlur,
})"
:style="{
zIndex: props.zIndex,
}"
/>
</Transition>
<DialogContent
:class="cn(sheetVariants({ side }), props.class)"
:style="{
zIndex: props.zIndex,
}"
v-bind="{ ...forwarded, ...$attrs }"
@animationend="emits('animationEnd')"
>
@@ -1,17 +1,13 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
@@ -1,17 +1,13 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
+1 -1
View File
@@ -11,7 +11,7 @@ export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'
export const sheetVariants = cva(
'fixed z-2000 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:animate-duration-300 data-[state=open]:animate-duration-500',
'fixed gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:animate-duration-300 data-[state=open]:animate-duration-500',
{
variants: {
side: {
@@ -1,23 +1,19 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Check } from 'lucide-vue-next'
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = withDefaults(
@@ -17,11 +17,7 @@ const props = withDefaults(
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,17 +1,13 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuItemProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
@@ -1,17 +1,13 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
@@ -1,24 +1,20 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Circle } from 'lucide-vue-next'
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,21 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSeparator,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
@@ -1,21 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
@@ -1,21 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRight } from 'lucide-vue-next'
import {
DropdownMenuSubTrigger,
useForwardProps,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
+231
View File
@@ -0,0 +1,231 @@
<script setup lang="ts">
import axios from 'axios'
import { filesize } from 'filesize'
import { toast } from 'vue-sonner'
defineOptions({
name: 'FaFileUpload',
})
const props = withDefaults(defineProps<{
action: string
method?: string
headers?: Headers | Record<string, any>
data?: Record<string, any>
name?: string
afterUpload?: (response: any) => string | Promise<string>
multiple?: boolean
ext?: string[]
size?: number
max?: number
hideTips?: boolean
disabled?: boolean
}>(), {
method: 'post',
headers: () => ({}),
data: () => ({}),
name: 'file',
multiple: false,
ext: () => [],
size: 10 * 1024 * 1024,
max: 0,
hideTips: false,
disabled: false,
})
const emits = defineEmits<{
onSuccess: [response: any, file: File]
onClick: [fileItem: FileItem, index: number]
}>()
const fileList = defineModel<FileItem[]>('modelValue', { required: true })
export interface FileItem {
name: string
size: number
url?: string
status?: 'uploading' | 'success' | 'error'
progress?: number
file?: File
}
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
const isDragging = ref(false)
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
return
}
isDragging.value = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
isDragging.value = false
}
function handleDrop(e: DragEvent) {
e.preventDefault()
if (props.disabled) {
return
}
isDragging.value = false
if (e.dataTransfer?.files) {
onSelectFile(e.dataTransfer.files)
}
}
function onSelectFile(files: FileList | File[] | null) {
if (!files) {
return
}
const selectedFiles = Array.from(files)
// 数量限制
const remain = props.max > 0 ? props.max - fileList.value.length : selectedFiles.length
const filesToAdd: File[] = []
for (const file of selectedFiles.slice(0, remain)) {
// 类型校验
if (props.ext.length > 0) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (!props.ext.map(e => e.toLowerCase()).includes(ext || '')) {
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式`)
continue
}
}
// 大小校验
if (props.size > 0) {
if (file.size > props.size) {
toast.error(`上传文件大小不能超过 ${filesize(props.size, { standard: 'jedec' })}`)
continue
}
}
filesToAdd.push(file)
}
fileInputRef.value!.value = ''
filesToAdd.forEach(file => uploadFile(file))
}
function uploadFile(file: File, index?: number) {
const formData = new FormData()
Object.entries(props.data).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append(props.name, file)
let headersObj: Record<string, any> = {}
if (props.headers instanceof Headers) {
props.headers.forEach((value, key) => {
headersObj[key] = value
})
}
else {
headersObj = { ...props.headers }
}
if (index === undefined) {
fileList.value.push({
name: file.name,
size: file.size,
status: 'uploading',
progress: 0,
file,
})
}
const currentFileIndex = index ?? fileList.value.length - 1
axios({
url: props.action,
method: props.method,
headers: headersObj,
data: formData,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
fileList.value[currentFileIndex].progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
}
},
})
.then(async (response) => {
const url = await props.afterUpload?.(response.data)
if (url) {
fileList.value[currentFileIndex].url = url
}
emits('onSuccess', response.data, file)
fileList.value[currentFileIndex].status = 'success'
})
.catch(() => {
fileList.value[currentFileIndex].status = 'error'
})
}
function removeFile(idx: number) {
fileList.value.splice(idx, 1)
}
</script>
<template>
<div class="space-y-2">
<button
type="button"
class="h-40 w-full flex flex-col cursor-pointer items-center justify-center border border-2 rounded-lg border-dashed bg-transparent p-4 transition-all"
:class="{
'border-primary bg-primary/5': isDragging,
'opacity-50 cursor-not-allowed': props.disabled || (props.max > 0 && fileList.length >= props.max),
}"
:disabled="props.disabled || (props.max > 0 && fileList.length >= props.max)"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="fileInputRef?.click()"
>
<FaIcon name="i-icon-park-outline:upload" class="mb-2 text-2xl text-card-foreground/50" />
<div class="text-sm text-card-foreground/70">
将文件拖到此处,或<span class="cursor-pointer text-primary font-bold">点击上传</span>
</div>
<input ref="fileInputRef" type="file" :multiple="props.multiple" :disabled="props.disabled" class="hidden" @change="e => onSelectFile((e.target as HTMLInputElement).files)">
</button>
<div v-if="!props.hideTips && !props.disabled" class="flex flex-wrap gap-1 text-xs text-card-foreground/50 empty:hidden">
<div v-if="props.ext.length > 0" class="after:content-[';_'] last:after:content-empty">
{{ `支持 ${props.ext.join(' / ')} 格式` }}
</div>
<div v-if="props.size > 0" class="after:content-[';_'] last:after:content-empty">
{{ `大小不超过 ${filesize(props.size, { standard: 'jedec' })}` }}
</div>
<div v-if="props.max > 0" class="after:content-[';_'] last:after:content-empty">
{{ `数量不超过 ${props.max} 个` }}
</div>
</div>
<div v-if="fileList.length > 0" class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
<div
v-for="(item, index) in fileList"
:key="item.name + index"
class="group/file-upload-item relative flex items-center gap-2 border rounded-lg py-2 pe-2 ps-3"
:class="[
item.status === 'error' ? 'border-red-500 bg-red-500/10' : '',
]"
@click="emits('onClick', item, index)"
>
<div class="flex-1 truncate">
<div class="truncate text-sm font-medium">
{{ item.name }}
</div>
<div class="text-xs text-card-foreground/50">
{{ filesize(item.size, { standard: 'jedec' }) }}
</div>
</div>
<div v-if="item.status === 'uploading'" class="pointer-events-none absolute inset-0 z-0 bg-primary/5" :style="{ width: `${item.progress}%` }" />
<div v-else-if="item.status === 'success'" class="size-10 flex-center">
<FaIcon name="i-ix:upload-success" class="text-lg text-green-600" />
</div>
<div v-else-if="item.status === 'error'" class="size-10 flex-center">
<FaIcon name="i-ix:upload-fail" class="text-lg text-red-600" />
</div>
<FaButtonGroup v-if="!props.disabled" class="absolute inset-e-2 top-1/2 opacity-0 transition-opacity -translate-y-1/2 group-hover/file-upload-item:opacity-100">
<FaButton v-if="item.status === 'error'" variant="outline" size="icon" @click.stop="uploadFile(item.file!, index)">
<FaIcon name="i-icon-park-outline:upload" class="cursor-pointer text-lg" />
</FaButton>
<FaButton v-if="item.status !== 'uploading'" variant="outline" size="icon" @click.stop="removeFile(index)">
<FaIcon name="i-icon-park-outline:delete" class="cursor-pointer text-lg text-red-500" />
</FaButton>
</FaButtonGroup>
</div>
</div>
</div>
</template>
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { HoverCardContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
HoverCardContent,
HoverCardPortal,
useForwardProps,
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = withDefaults(
@@ -16,11 +16,7 @@ const props = withDefaults(
},
)
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits & { animationEnd: [] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-2000 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed z-2000 inset-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class,
)"
@animationend="emits('animationEnd')"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring"
>
<X class="h-8 w-8" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

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