Compare commits

..

136 Commits
v0.9 ... main

Author SHA1 Message Date
puxiao
4facdbf9b7
fix: add the autocomplete attribute to the username/password input. (#54) 2025-10-30 10:43:45 +08:00
chansee97
8417432343 fix: 移除自动代理环境名选择 2025-08-30 22:32:46 +08:00
tu6ge
f2e82e725f
chore: add naive-ui-intelligence in vscode plugin recommendations (#53) 2025-08-21 16:32:51 +08:00
chansee97
ac9d7e84e5 fix: 修改全局地址映射名字 2025-08-20 17:46:46 +08:00
chansee97
050ab3e7ed fix: 修复正式环境地址错误 2025-08-20 17:43:28 +08:00
chansee97
79501a53f0 fix: 移除test环境示例 2025-08-19 23:15:31 +08:00
chansee97
501b64e884 fix: 精简路由,重新规范路径 2025-08-18 23:53:25 +08:00
chansee97
03a7891ed4 fix: 修复动态路由重定向问题 2025-08-18 22:40:29 +08:00
chansee97
e66e4fb17c fix: active menu error 2025-08-08 14:32:33 +08:00
chansee97
ade869ea30 fix: use suspense for apploading 2025-08-05 22:24:14 +08:00
chansee97
a1085e1922 fix: full-content padding error 2025-08-05 16:44:49 +08:00
chansee97
2535252310 fix: improve mobile layout 2025-08-02 23:19:34 +08:00
chansee97
228ccaee5b chore: updata deps 2025-08-02 01:11:43 +08:00
chansee97
3144acbc39 feat: adapted mobile screen 2025-08-02 01:06:47 +08:00
chansee97
1b4639d5d8 fix: full content error 2025-08-01 11:33:49 +08:00
chansee97
4e08b796c0 feat: update the layout mode 2025-08-01 10:52:29 +08:00
chansee97
fcd28fc9d5 Merge branch 'main' of https://github.com/chansee97/nova-admin 2025-07-11 22:15:49 +08:00
chansee97
9ff5d215da feat: 添加自动代理功能并更新相关配置 2025-07-11 22:15:29 +08:00
chansee97
b816331da5 fix: tsconfig error 2025-07-11 22:09:34 +08:00
chansee97
8e201f8bd8 feat: add public page 2025-07-03 10:53:29 +08:00
chansee97
5bb370430e fix: typo 2025-06-21 23:49:34 +08:00
chansee97
6d643fe7e1 fix: add key-field for DropTab 2025-06-20 15:23:09 +08:00
chansee97
0b0ae4964e fix: typo 2025-06-20 10:21:41 +08:00
chansee97
3e4e9005de feat: 更新IconSelect组件,支持未分类图标并优化图标数据处理逻辑 2025-06-18 17:14:57 +08:00
chansee97
bc968dc7f7 fix: Copy instruction judgment logic. 2025-06-17 16:34:19 +08:00
chansee97
56f6415ef2 fix: fix example problem 2025-06-16 16:02:25 +08:00
chansee97
0d7d851dd6 fix: adjust style in tab bar 2025-04-30 16:15:24 +08:00
goodman
27d081cb23
feat: add scrollable tab bar (#49)
* feat: add scrollbar support and enhance tab navigation  finish:1/2

* update

* feat: 添加当前tab滚动功能并优化滚动条样式

* feat: 重构tab滚动逻辑,新增useTabScroll钩子以优化当前tab滚动体验

* feat: 优化TabBar组件,移除冗余代码并整合useTabScroll钩子

* fix: silly bug

* refactor: Remove the debugging log

---------

Co-authored-by: Vigo.zhou <eq1024@foxmail.com>
2025-04-30 16:03:35 +08:00
chansee97
44ebd5f19e chore: update dependencies 2025-04-16 11:52:12 +08:00
chansee97
8450e5159f chore: update readme 2025-04-09 19:25:20 +08:00
chansee97
f0647c7697 fix: type error 2025-04-09 19:20:02 +08:00
chansee97
a5c3697a6b chore: bump version to 0.9.12 and update dependencies 2025-04-05 17:43:46 +08:00
chansee97
d97306d5b5 chore: bump version to 0.9.11 2025-03-27 22:08:06 +08:00
chenxin
99840ed604 style: add cursor pointer style to user avatar 2025-03-14 14:52:43 +08:00
chansee97
75dd7b0c83 chore: update dependencies to latest versions 2025-03-05 21:28:54 +08:00
chansee97
2f2d8726d4 Merge branch 'main' of https://github.com/chansee97/nova-admin 2025-01-20 22:47:46 +08:00
chansee97
30f0ac0904 feat: remove support single role permission 2025-01-20 22:47:43 +08:00
Rock Chen
5cb0ca39dd feat: add draggable tab bar items and refactor tab rendering 2025-01-16 13:12:24 +00:00
chansee97
e5222bbbc6 docs: update WeChat group information and clarify adding purpose 2025-01-08 17:07:52 +08:00
chansee97
52614819e8 chore: update version to 0.9.10 2025-01-06 11:50:26 +08:00
chansee97
4f31476cf8 chore: updata deps 2025-01-05 12:35:41 +08:00
chansee97
85b0826eea feat: file collpse 2024-12-24 10:59:19 +08:00
chansee97
5975d05437 fix: copy i18n 2024-12-24 10:45:44 +08:00
AnsonCar
aa9aece0ca
feat: Add docker support to enhance project scalability and deployment efficiency (#43)
* docs: update readme Install and use

* feat: add docker

* feat: add nginx.conf

* fix: change Traditional Chinese to Simplified Chinese
2024-11-23 20:47:56 +08:00
chansee97
a487141cfb chore: updata deps 2024-11-06 22:56:31 +08:00
chansee97
e7148081a9 modify: support multi tabs 2024-10-08 12:37:27 +08:00
chansee97
308e0b4bf9 fix: rich editor error type 2024-09-26 22:44:44 +08:00
Ray.D.Song
367406258c
fix: Sass legacy JS API warn (#39)
* feat: draggable list & useTableDrag hook

* fix: sass deprecated legacy JS API
2024-09-25 12:33:57 +08:00
Ray.D.Song
aee3e52f15
feat: draggable list & useTableDrag hook (#38) 2024-09-24 11:23:52 +08:00
chansee97
89d78b7ec7 fix: useEcharts updata error 2024-09-22 23:23:15 +08:00
chansee97
242c94723b fix: dict function error 2024-09-19 00:50:10 +08:00
chansee97
dc3563969b fix: import error 2024-09-16 00:03:06 +08:00
chansee97
7da454563b fix: import order 2024-09-14 08:09:20 +08:00
chenxin
e450a029ac feat: adapt vue3.5 feat 2024-09-05 17:05:55 +08:00
chenxin
c14ebc2343 refactor: organize file dir 2024-09-05 16:02:24 +08:00
chansee97
5e88b8d01f fix: map name error 2024-08-31 00:35:39 +08:00
chansee97
5fb8881763 fix: modify echart bg color 2024-08-24 00:43:31 +08:00
chansee97
efc1ccbc9a fix: remove DemoList type 2024-08-21 00:21:57 +08:00
chansee97
f20d2a5fb6 fix:typo 2024-08-21 00:07:17 +08:00
chansee97
fad054e8df fix: chart & qrcode demo error 2024-08-19 08:26:37 +08:00
chansee97
df0cf9f72b chore: update deps 2024-08-14 22:33:01 +08:00
chansee97
d69bd796a4 refactor: env var 2024-08-09 00:49:55 +08:00
TianQian
a9c7708119
fix: 为 createServerTokenAuthentication 添加 statesHook 类型 (#32) 2024-08-08 23:48:09 +08:00
chansee97
de4cd17548 chore: update alova version 2024-08-03 07:02:28 +08:00
chansee97
70c43a276c fix: type error 2024-07-29 22:31:48 +08:00
chansee97
5cc410c7b4 fix: type error & local icon error 2024-07-25 22:40:12 +08:00
chansee97
5c24fa1502 fix: readme error mock url 2024-07-20 01:04:51 +08:00
chansee97
4d82a24d22 chore: update deps 2024-07-20 00:54:13 +08:00
chansee97
20d9fbef2e fix: close i18n warn 2024-07-20 00:28:43 +08:00
chansee97
4b1d3f2912 fix: typo 2024-07-16 18:44:50 +08:00
chansee97
6c03ef53a3 fix: header style error 2024-07-15 13:13:55 +08:00
chansee97
23afe39c65 feat: add content full screen 2024-07-12 14:41:48 +08:00
chansee97
309d723e43 fix: dict error import 2024-07-12 13:35:11 +08:00
chansee97
0e9bf396f3 feat: move dict to utils 2024-07-12 13:31:56 +08:00
chansee97
2c0b2fb26c Merge branch 'main' of https://github.com/chansee97/nova-admin 2024-07-09 14:34:18 +08:00
chansee97
daf3cf9ca7 docs: modify site url 2024-07-09 14:20:18 +08:00
chansee97
48054e04f9 fix: perfect dict menu 2024-07-07 00:29:04 +08:00
chansee97
6b40f45ae3 feat: add dict utils 2024-07-06 02:53:57 +08:00
chansee97
0741c564dd fix: init rich-text fail 2024-06-30 14:40:55 +08:00
chansee97
2305fff569 fix: required dictValue 2024-06-27 00:28:41 +08:00
chansee97
cf76ef71f7 feat: add dict page 2024-06-27 00:24:34 +08:00
chansee97
e7c6f7c177 Merge branch 'main' of https://github.com/chansee97/nova-admin 2024-06-26 21:00:36 +08:00
chansee97
eb82842fad fix: perfect copy componet 2024-06-26 21:00:08 +08:00
chansee97
e1d440b45a fix: perfect copy componet 2024-06-26 20:41:39 +08:00
chansee97
1a1ffcb9aa chore: remove pinia-plugin-persist 2024-06-26 19:28:25 +08:00
chansee97
8981f42571 fix: layout typo 2024-06-26 19:19:29 +08:00
chansee97
21544139df fix: update vue-devtools 2024-06-25 00:51:56 +08:00
chansee97
9ce6bd3b86 feat: add mix layout 2024-06-25 00:27:37 +08:00
chansee97
8f5f11f4d3 chore: update dps 2024-06-17 10:30:40 +08:00
chansee97
9aed794344 feat: add switch autofresh token 2024-06-13 12:35:16 +08:00
chansee97
808c4d0cdf fix: check route redirect target 2024-06-12 15:41:05 +08:00
chansee97
6449845ab6 fix: icon render error 2024-06-10 13:07:05 +08:00
chansee97
46766a54fb feat: local svg icon 2024-06-10 12:38:27 +08:00
chansee97
57739f960b fix: revert useDefault 2024-06-08 13:58:09 +08:00
chansee97
530231a5cb fix: check appRoot 2024-06-08 09:23:47 +08:00
chansee97
86ef62f841 fix: modify logout name 2024-06-07 23:58:02 +08:00
chansee97
89e8b0e3e1 fix: modify apploading 2024-06-07 23:15:27 +08:00
chansee97
ec85ffaea2 fix: check window.$message 2024-06-07 21:39:46 +08:00
chansee97
02ce6568b7 fix: modify logout name 2024-06-07 21:04:57 +08:00
viarotel
8a5d8a67ea
fix: 🐛 fix: Word spelling error (#17) 2024-06-07 10:43:27 +08:00
chansee97
f426bffbc7 fix: cancel lock node vision 2024-06-07 07:28:28 +08:00
chansee97
8ead40457b chore: remove arrayToTree 2024-06-07 07:22:39 +08:00
chansee97
39d185b132 fix: remove meta. perfix 2024-06-06 23:35:55 +08:00
chansee97
bf5445b6e4 fix: lock node vision 2024-06-05 17:52:21 +08:00
chansee97
bf1cfcdd27 fix: jiti install error 2024-06-05 15:54:27 +08:00
chansee97
a149e301dd chore: update vision 2024-06-05 15:27:08 +08:00
chansee97
e201ff071f fix: login fetch error 2024-06-05 09:53:46 +08:00
chansee97
4aa3a66ce9 feat: add useDefault 2024-06-05 09:18:21 +08:00
chansee97
448f3ba494 fix: perfect hooks 2024-06-04 20:43:09 +08:00
chansee97
648a0ba098 fix: remove useLoading 2024-06-03 22:10:08 +08:00
chansee97
5945e63324 fix: tsx type error 2024-05-31 09:26:07 +08:00
chansee97
712bd53bf9 docs: modify badgen 2024-05-28 22:06:49 +08:00
chansee97
2fc1dd4467 fix: tabs style error 2024-05-28 21:47:32 +08:00
chansee97
5f7c77d9c6 perf: perfect entity type 2024-05-27 11:30:15 +08:00
chansee97
4dde8b78dd chore: remove qs 2024-05-25 14:40:44 +08:00
chansee97
8419f29d84 chore: updata deps 2024-05-24 10:00:37 +08:00
chansee97
04a93e667b feat: add user manage 2024-05-23 23:45:58 +08:00
chansee97
4959d0f1b9 fix: add naive hooks 2024-05-23 08:43:47 +08:00
chansee97
bdc8764a2b fix: perfect menu 2024-05-21 12:35:04 +08:00
chansee97
344baa7cd1 fix: fullscreen and browser conficts 2024-05-14 19:59:48 +08:00
chansee97
8bafc3aa36 feat: add PcaCascader 2024-05-12 13:27:23 +08:00
chansee97
922e82d12f feat: replace rice text editor to quill2.0 2024-05-12 02:51:31 +08:00
chansee97
3b3b964067 fix: perfect search modal code 2024-05-11 22:55:13 +08:00
chansee97
35df1832f4 fix: perfect search modal 2024-05-11 22:45:31 +08:00
chansee97
df6c8e5aef fix: Ensure parent node expands in Tab routing switch
Co-authored-by: JuneOY <JuneOY@users.noreply.github.com>
2024-05-08 14:35:18 +08:00
chansee97
8ae6acd62d fix: modify search modal height 2024-05-06 10:38:25 +08:00
chansee97
1387db71bc fix: update relaease node version 2024-05-05 16:50:57 +08:00
chansee97
1ccc3f371a feat: perfect search modal 2024-05-05 16:21:07 +08:00
chansee97
61bbdedec1 fix: delete plugin/fetch 2024-04-28 10:06:58 +08:00
JuneOY
187c26832c
fix: typo (#12)
* updata plugin fetch layout

* fix:typo
2024-04-28 10:03:47 +08:00
chansee97
6ea0c7645f feat: prefect icon seletor 2024-04-27 01:17:11 +08:00
chansee97
a9626d3ace fix: type error 2024-04-26 16:36:15 +08:00
chansee97
c7483141ce fix: typo 2024-04-24 09:14:47 +08:00
chansee97
b2c4585927 fix: layout scoped 2024-04-24 09:10:46 +08:00
chansee97
21a51b935c feat: add issue template 2024-04-23 11:25:30 +08:00
chansee97
5046531816 docs: install description 2024-04-22 15:31:23 +08:00
170 changed files with 5682 additions and 2560 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
/node_modules
/.git
/.gitignore
/.vscode
/.DS_Store
/*.md
/dist

18
.env
View File

@ -1,14 +1,26 @@
# 项目根目录 # 项目根目录
VITE_BASE_URL = / VITE_BASE_URL = /
# 项目名称 # 项目名称
VITE_APP_NAME = Nova - Admin VITE_APP_NAME = Nova - Admin
# 路由模式
# 路由模式 web hash
VITE_ROUTE_MODE = web VITE_ROUTE_MODE = web
# 权限路由模式: static dynamic
VITE_AUTH_ROUTE_MODE=static # 路由加载模式 static dynamic
VITE_ROUTE_LOAD_MODE = static
# 设置登陆后跳转地址 # 设置登陆后跳转地址
VITE_HOME_PATH = /dashboard/workbench VITE_HOME_PATH = /dashboard/workbench
# 本地存储前缀 # 本地存储前缀
VITE_STORAGE_PREFIX = VITE_STORAGE_PREFIX =
# 版权信息
VITE_COPYRIGHT_INFO = Copyright © 2024 chansee97
# 自动刷新token
VITE_AUTO_REFRESH_TOKEN = N
# 默认多语言 enUS | zhCN
VITE_DEFAULT_LANG = enUS

View File

@ -1,6 +0,0 @@
# 是否开启压缩资源
VITE_BUILD_COMPRESS=N
# 压缩算法 gzip | brotliCompress | deflate | deflateRaw
VITE_COMPRESS_TYPE=gzip

42
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: 🐞 Bug report
description: Create a report to help us improve
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: bug-description
attributes:
label: Description
description: Please explain clearly how the bug reappears. If possible, it is best to add the cause of the problem.
placeholder: bug description
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected
validations:
required: true
- type: textarea
id: additional-comments
attributes:
label: Additional comments
description: e.g. some background/context of how you ran into this bug.
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Ensure this issue not a bug proposal.
required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
required: true
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -0,0 +1,45 @@
name: ✨ New feature
description: Propose a new feature to be added to Nova-admin
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to fill out this feature report!
- type: textarea
id: feature-description
attributes:
label: Description
description: Clear and concise description of the problem. Please make the reason and usecases as detailed as possible. If you intend to submit a PR for this issue, tell us in the description. Thanks!
placeholder: As a developer using Nova-admin I want [goal / wish] so that [benefit]...
validations:
required: true
- type: textarea
id: suggested-solution
attributes:
label: Suggestion
description: In module [xy] we could provide following implementation...
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Any other context or screenshots about the feature request here.
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Ensure this issue not a feature proposal.
required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
required: true
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
required: true

31
.github/ISSUE_TEMPLATE/others.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: 👓 Others
description: Create an issue for Nova-admin
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to create this issue!
- type: textarea
id: description
attributes:
label: Description
description: Clear and concise description of the issue. Thanks!
placeholder: There are some thing I want to ...
validations:
required: true
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Ensure this issue neither a bug report nor a feature proposal.
required: true
- label: Read the [docs](https://nova-admin-docs.pages.dev/).
required: true
- label: Check that there isn't [already an issue](https://github.com/chansee97/nova-admin/issues) that descript the same thing to avoid creating a duplicate.
required: true

View File

@ -15,11 +15,12 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
node-version: 20.x
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16.x node-version: 20.x
- run: npx changelogithub # or changelogithub@0.12 if ensure the stable result - run: npx changelogithub
env: env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

11
.gitignore vendored
View File

@ -25,8 +25,9 @@ stats.html
*.sln *.sln
*.sw? *.sw?
/src/typings/components.d.ts components.d.ts
/src/typings/auto-imports.d.ts auto-imports.d.ts
pnpm-lock.yaml auto-proxy.d.ts
package-lock.json
yarn.lock # Lock files
*-lock.yaml

View File

@ -10,6 +10,7 @@
"antfu.iconify", "antfu.iconify",
"kisstkondoros.vscode-gutter-preview", "kisstkondoros.vscode-gutter-preview",
"antfu.unocss", "antfu.unocss",
"vue.volar" "vue.volar",
"tu6ge.naive-ui-intelligence"
] ]
} }

76
.vscode/settings.json vendored
View File

@ -1,6 +1,4 @@
{ {
// Enable the ESlint flat config support
"eslint.experimental.useFlatConfig": true,
// Disable the default formatter, use eslint instead // Disable the default formatter, use eslint instead
"prettier.enable": false, "prettier.enable": false,
"editor.formatOnSave": false, "editor.formatOnSave": false,
@ -11,46 +9,16 @@
}, },
// Silent the stylistic rules in you IDE, but still auto fix them // Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [ "eslint.rules.customizations": [
{ { "rule": "style/*", "severity": "off" },
"rule": "style/*", { "rule": "format/*", "severity": "off" },
"severity": "off" { "rule": "*-indent", "severity": "off" },
}, { "rule": "*-spacing", "severity": "off" },
{ { "rule": "*-spaces", "severity": "off" },
"rule": "format/*", { "rule": "*-order", "severity": "off" },
"severity": "off" { "rule": "*-dangle", "severity": "off" },
}, { "rule": "*-newline", "severity": "off" },
{ { "rule": "*quotes", "severity": "off" },
"rule": "*-indent", { "rule": "*semi", "severity": "off" }
"severity": "off"
},
{
"rule": "*-spacing",
"severity": "off"
},
{
"rule": "*-spaces",
"severity": "off"
},
{
"rule": "*-order",
"severity": "off"
},
{
"rule": "*-dangle",
"severity": "off"
},
{
"rule": "*-newline",
"severity": "off"
},
{
"rule": "*quotes",
"severity": "off"
},
{
"rule": "*semi",
"severity": "off"
}
], ],
// Enable eslint for all supported languages // Enable eslint for all supported languages
"eslint.validate": [ "eslint.validate": [
@ -64,7 +32,16 @@
"json", "json",
"jsonc", "jsonc",
"yaml", "yaml",
"toml" "toml",
"xml",
"gql",
"graphql",
"astro",
"css",
"less",
"scss",
"pcss",
"postcss"
], ],
"i18n-ally.sourceLanguage": "zh_CN", "i18n-ally.sourceLanguage": "zh_CN",
"i18n-ally.displayLanguage": "zh_CN", "i18n-ally.displayLanguage": "zh_CN",
@ -74,5 +51,16 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"locales" "locales"
], ],
"commentTranslate.source": "Google" // File collapse
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "$(capture).test.ts, $(capture).test.tsx, $(capture).spec.ts, $(capture).spec.tsx, $(capture).d.ts",
"*.tsx": "$(capture).test.ts, $(capture).test.tsx, $(capture).spec.ts, $(capture).spec.tsx,$(capture).d.ts",
"*.env": "$(capture).env.*",
"README.md": "README*,CHANGELOG*,LICENSE,CNAME",
"package.json": "pnpm-lock.yaml,pnpm-workspace.yaml,.gitattributes,.gitignore,.gitpod.yml,.npmrc,.browserslistrc,.node-version,.git*,.tazerc.json",
"eslint.config.js": ".eslintignore,.prettierignore,.stylelintignore,.commitlintrc.*,.prettierrc.*,stylelint.config.*,.lintstagedrc.mjs,cspell.json",
"docker-compose.product.yml": ".dockerignore,nginx.conf"
}
} }

View File

@ -5,7 +5,8 @@
<div align="center"> <div align="center">
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/> <img src="https://img.shields.io/github/license/chansee97/nova-admin"/>
<img src="https://img.shields.io/github/stars/chansee97/nova-admin"/> <img src="https://badgen.net/github/stars/chansee97/nova-admin?icon=github"/>
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/> <img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
</div> </div>
@ -18,19 +19,19 @@
[Nova-admin](https://github.com/chansee97/nova-admin) is a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI. It implements complete functionality in a simple way, while also considering code standards, readability, and avoiding excessive encapsulation to facilitate secondary development. [Nova-admin](https://github.com/chansee97/nova-admin) is a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI. It implements complete functionality in a simple way, while also considering code standards, readability, and avoiding excessive encapsulation to facilitate secondary development.
- [Nova-Admin preview](https://nova-admin-site.netlify.app/) - [Nova-Admin preview](https://nova-admin.pages.dev/)
- [Nova-Admin docs](https://nova-admin-docs.netlify.app/) - [Nova-Admin docs](https://nova-admin-docs.pages.dev/)
## Features ## Features
- Developed based on the latest technology stack including Vue3, Vite5, TypeScript, NaiveUI, Unocss, etc. - Developed based on the latest technology stack including Vue3, Vite6, TypeScript, NaiveUI, Unocss, etc.
- Based on [alova](https://alova.js.org/) encapsulation and configuration, providing unified response handling and multi-scenario capabilities. - Based on [alova](https://alova.js.org/) encapsulation and configuration, providing unified response handling and multi-scenario capabilities.
- Comprehensive front-end and back-end permission management solution. - Comprehensive front-end and back-end permission management solution.
- Supports local static routes and dynamically generated routes from the back end, with easy route configuration. - Supports local static routes and dynamically generated routes from the back end, with easy route configuration.
- Secondary encapsulation of commonly used components to meet basic work requirements. - Secondary encapsulation of commonly used components to meet basic work requirements.
- Dark theme adaptation, maintaining the Naive style for interface aesthetics. - Dark theme adaptation, maintaining the Naive style for interface aesthetics.
- Only performs eslint validation during submission without excessive restrictions for simpler development. - Only performs eslint validation during submission without excessive restrictions for simpler development.
- Flexible and configurable interface style layout. - Flexible and configurable interface layout based on [pro-naive-ui](https://github.com/Zheng-Changfu/pro-naive-ui)
- Multilanguage (i18n) support. - Multilanguage (i18n) support.
## Project preview ## Project preview
@ -47,13 +48,16 @@
- [Gitee](https://gitee.com/chansee97/nova-admin) - [Gitee](https://gitee.com/chansee97/nova-admin)
- [Github](https://github.com/chansee97/nova-admin) - [Github](https://github.com/chansee97/nova-admin)
## Related projects ## Interface document
- [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm This project uses ApiFox for interface mock, check the online documentation for more interface details
[online aipfox docs](https://nova-admin.apifox.cn)
## Install and use ## Install and use
The local development environment is recommended to use pnpm 8.x, Node.js must be version 20.x. The local development environment is recommended to use pnpm 10.x, Node.js version 21.x.
It is recommended to directly download the compressed package from [Releases](https://github.com/chansee97/nova-admin/releases)
```bash ```bash
# install dependencies # install dependencies
@ -67,20 +71,26 @@ pnpm build
``` ```
## Interface document You can deploy **nova-admin** in a production environment using docker-compose.
```bash
# Build product
docker compose -f docker-compose.product.yml up --build -d
```
> The nginx.conf provided is for reference only. You can adjust it according to your own needs.
This project uses ApiFox for interface mock, check the online documentation for more interface details ## Related projects
[online aipfox docs](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
- [Nova-admin-nest](https://github.com/chansee97/nova-admin-nest) (under development) Nova-Admin supporting background project based on TS, NestJs, typeorm
## Learn to communicate ## Learn to communicate
Nova-Admin is a completely open-source and free project. It is still being optimized and iterated. It is designed to help developers more conveniently develop medium and large management systems. If you have any questions, please ask questions in the QQ exchange group. Nova-Admin is a completely open-source and free project. It is still being optimized and iterated. It is designed to help developers more conveniently develop medium and large management systems. If you have any questions, please ask questions in the QQ exchange group.
| Q-Group | wechat-Group |wechat | | Q-Group | wechat-Group |
| :--: |:--: |:--: | | :--: |:--: |
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/wx-group.png" width=170>|<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>| | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> |<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
> The WeChat group QR code is invalid, please add me as a friend. > Please indicate the purpose of adding WeChat.
## Contribution ## Contribution

View File

@ -5,7 +5,8 @@
<div align="center"> <div align="center">
<img src="https://img.shields.io/github/license/chansee97/nova-admin"/> <img src="https://img.shields.io/github/license/chansee97/nova-admin"/>
<img src="https://img.shields.io/github/stars/chansee97/nova-admin"/> <img src="https://badgen.net/github/stars/chansee97/nova-admin?icon=github"/>
<img src="https://gitee.com/chansee97/nova-admin/badge/star.svg"/>
<img src="https://img.shields.io/github/forks/chansee97/nova-admin"/> <img src="https://img.shields.io/github/forks/chansee97/nova-admin"/>
</div> </div>
@ -18,19 +19,19 @@
[Nova-admin](https://github.com/chansee97/nova-admin)是一个基于Vue3、Vite5、Typescript、Naive UI, 简洁干净后台管理模板,用简单的方式实现完整功能,并尽可能的考虑代码规范,易读易理解无过度封装,方便二次开发。 [Nova-admin](https://github.com/chansee97/nova-admin)是一个基于Vue3、Vite5、Typescript、Naive UI, 简洁干净后台管理模板,用简单的方式实现完整功能,并尽可能的考虑代码规范,易读易理解无过度封装,方便二次开发。
- [Nova-Admin 预览](https://nova-admin-site.netlify.app/) - [Nova-Admin 预览](https://nova-admin.pages.dev/)
- [Nova-Admin 文档](https://nova-admin-docs.netlify.app/) - [Nova-Admin 文档](https://nova-admin-docs.pages.dev/)
## 特性 ## 特性
- 基于Vue3、Vite5、TypeScript、NaiveUI、Unocss等最新技术栈开发 - 基于Vue3、Vite6、TypeScript、NaiveUI、Unocss等最新技术栈开发
- 基于[alova](https://alova.js.org/)封装和配置,提供统一的响应处理和多场景能力 - 基于[alova](https://alova.js.org/)封装和配置,提供统一的响应处理和多场景能力
- 完善的前后端权限管理方案 - 完善的前后端权限管理方案
- 支持本地静态路由和后台返回动态路由,路由简单易配置 - 支持本地静态路由和后台返回动态路由,路由简单易配置
- 对日常使用频率较高的组件二次封装,满足基础工作需求 - 对日常使用频率较高的组件二次封装,满足基础工作需求
- 黑暗主题适配, 界面样式保持Naive风格 - 黑暗主题适配, 界面样式保持Naive风格
- 仅在提交时进行eslint校验没有过多限制开发更简便 - 仅在提交时进行eslint校验没有过多限制开发更简便
- 界面样式布局灵活可配置 - 基于[pro-naive-ui](https://github.com/Zheng-Changfu/pro-naive-ui)的界面布局灵活可配置
- 多语言i18n支持 - 多语言i18n支持
## 项目预览 ## 项目预览
@ -47,13 +48,16 @@
- [Gitee](https://gitee.com/chansee97/nova-admin) - [Gitee](https://gitee.com/chansee97/nova-admin)
- [Github](https://github.com/chansee97/nova-admin) - [Github](https://github.com/chansee97/nova-admin)
## 相关项目 ## 接口文档
- [Nova-admin-nest](https://github.com/chansee97/nove-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目 本项目使用ApiFox进行接口mock查看在线文档以了解更多接口详情
[在线apifox文档](https://nova-admin.apifox.cn)
## 安装使用 ## 安装使用
本地开发环境建议使用 pnpm 8.x 、Node.js 必须 20.x 本地开发环境建议使用 pnpm 10.x 、Node.js 21.x
推荐直接下载[Releases](https://github.com/chansee97/nova-admin/releases)压缩包
```bash ```bash
# install dependencies # install dependencies
@ -67,20 +71,26 @@ pnpm build
``` ```
## 接口文档 在生产环境也可以使用 docker-compose 部署 **nova-admin**
```bash
# Build product
docker compose -f docker-compose.product.yml up --build -d
```
> 关于 nginx.conf 只供参考,你可以根据自己的需求进行调整。
本项目使用ApiFox进行接口mock查看在线文档以了解更多接口详情 ## 相关项目
[在线apifox文档](https://apifox.com/apidoc/shared-2b1abeb5-6e78-425e-a4ff-d7277ae83bf0)
- [Nova-admin-nest](https://github.com/chansee97/nova-admin-nest) (开发中)基于TS, NestJs, typeorm的Nova-Admin配套后台项目
## 学习交流 ## 学习交流
Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨在帮助开发者更方便地进行中大型管理系统开发,有使用问题欢迎在交流群内提问。 Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨在帮助开发者更方便地进行中大型管理系统开发,有使用问题欢迎在交流群内提问。
| Q群 | 微信群 | 个人微信 | | Q群 | 微信群 |
| :--: |:--: |:--: | | :--: |:--: |
| <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/wx-group.png" width=170>|<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>| | <img src="https://cdn.jsdelivr.net/gh/chansee97/static/nova-admin/q-group.png" width=170> |<img src="https://cdn.jsdelivr.net/gh/chansee97/static/wechat.png" width=170>|
> 微信群二维码失效请加我为好友 > 添加微信请注明来意
## 贡献 ## 贡献
@ -101,6 +111,7 @@ Nova-Admin 是完全开源免费的项目,目前仍然在优化迭代中,旨
<a href="https://github.com/chansee97/nova-admin/graphs/contributors"> <a href="https://github.com/chansee97/nova-admin/graphs/contributors">
<img src="https://contrib.rocks/image?repo=chansee97/nova-admin" alt="contributors" /> <img src="https://contrib.rocks/image?repo=chansee97/nova-admin" alt="contributors" />
</a> </a>
## Star 历史 ## Star 历史
[![Star History Chart](https://api.star-history.com/svg?repos=chansee97/nova-admin&type=Date)](https://star-history.com/#chansee97/nova-admin&Date) [![Star History Chart](https://api.star-history.com/svg?repos=chansee97/nova-admin&type=Date)](https://star-history.com/#chansee97/nova-admin&Date)

226
build/autoProxy.ts Normal file
View File

@ -0,0 +1,226 @@
import type { ProxyOptions, UserConfig } from 'vite'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
/** 服务配置接口 */
interface ServiceConfig {
[key: string]: string
}
/** 服务环境类型 */
type ServiceEnvType = string
/** 完整的服务配置类型 */
interface FullServiceConfig {
[key: ServiceEnvType]: ServiceConfig
}
/** 代理项接口 */
interface ProxyItem {
/** 代理路径 */
path: string
/** 原始地址 */
rawPath: string
}
/** 代理地址映射接口 */
interface ProxyMapping {
[serviceName: string]: ProxyItem
}
/** 插件选项接口 */
export interface ServiceProxyPluginOptions {
/** 服务配置对象(必填) */
serviceConfig: FullServiceConfig
/** 代理路径前缀(可选,默认为 'proxy-' */
proxyPrefix?: string
/** 是否启用代理配置 */
enableProxy?: boolean
/** 环境变量名(可选,默认为 '__URL_MAP__' */
envName?: string
/** d.ts 类型文件生成路径(可选,如果传入路径则在该路径生成 d.ts 类型文件) */
dts?: string
}
export default function createServiceProxyPlugin(options: ServiceProxyPluginOptions) {
const {
serviceConfig,
proxyPrefix = 'proxy-',
enableProxy = true,
envName = '__URL_MAP__',
dts,
} = options
return {
name: 'vite-auto-proxy',
config(config: UserConfig, { mode, command }: { mode: string, command: 'build' | 'serve' }) {
// 只在开发环境serve命令时生成代理配置
const isDev = command === 'serve'
// 在非开发环境也注入空的代理映射,避免运行时错误
if (!config.define) {
config.define = {}
}
if (!enableProxy || !isDev) {
const rawMapping: ProxyMapping = {}
const envConfig = serviceConfig[mode]
if (envConfig) {
Object.entries(envConfig).forEach(([serviceName, serviceUrl]) => {
rawMapping[serviceName] = {
path: serviceUrl,
rawPath: serviceUrl,
}
})
console.warn(`[auto-proxy] 已加载 ${Object.keys(envConfig).length} 个服务地址`)
}
else {
console.warn(`[auto-proxy] 未找到环境 "${mode}" 的配置`)
}
config.define[envName] = JSON.stringify(rawMapping)
// 生成 d.ts 类型文件(如果指定了路径)
if (dts) {
generateDtsFile(rawMapping, dts, envName)
}
return
}
console.warn(`[auto-proxy] 已加载${mode}模式 ${Object.keys(serviceConfig[mode]).length} 个服务地址`)
const { proxyConfig, proxyMapping } = generateProxyFromServiceConfig(serviceConfig, mode, proxyPrefix)
Object.entries(proxyMapping).forEach(([serviceName, proxyItem]) => {
console.warn(`[auto-proxy] 服务: ${serviceName} | 代理地址: ${proxyItem.path} | 实际地址: ${proxyItem.rawPath}`)
})
if (proxyConfig && Object.keys(proxyConfig).length > 0) {
// 确保 server 对象存在
if (!config.server) {
config.server = {}
}
// 合并代理配置
config.server.proxy = {
...config.server.proxy,
...proxyConfig,
}
config.define[envName] = JSON.stringify(proxyMapping)
console.warn(`[auto-proxy] 代理映射已注入到 ${envName}`)
// 生成 d.ts 类型文件(如果指定了路径)
if (dts) {
generateDtsFile(proxyMapping, dts, envName)
}
}
else {
console.warn(`[auto-proxy] 未生成任何代理配置`)
config.define[envName] = JSON.stringify({})
// 生成空的 d.ts 类型文件(如果指定了路径)
if (dts) {
generateDtsFile({}, dts, envName)
}
}
},
}
}
function generateProxyFromServiceConfig(
serviceConfig: FullServiceConfig,
mode: ServiceEnvType,
proxyPrefix: string,
): { proxyConfig: Record<string, ProxyOptions>, proxyMapping: ProxyMapping } {
try {
// 获取当前环境的配置
const envConfig = serviceConfig[mode]
if (!envConfig) {
console.warn(`[auto-proxy] 未找到环境 "${mode}" 的配置,使用 development 配置`)
const defaultConfig = serviceConfig.development
if (!defaultConfig) {
console.error(`[auto-proxy] 也未找到 development 配置`)
return { proxyConfig: {}, proxyMapping: {} }
}
return generateProxyFromConfig(defaultConfig, proxyPrefix)
}
return generateProxyFromConfig(envConfig, proxyPrefix)
}
catch (error) {
console.error(`[auto-proxy] 生成代理配置失败:`, (error as Error).message)
return { proxyConfig: {}, proxyMapping: {} }
}
}
function generateProxyFromConfig(
envConfig: ServiceConfig,
proxyPrefix: string,
): { proxyConfig: Record<string, ProxyOptions>, proxyMapping: ProxyMapping } {
const proxyConfig: Record<string, ProxyOptions> = {}
const proxyMapping: ProxyMapping = {}
Object.entries(envConfig).forEach(([serviceName, serviceUrl]) => {
if (typeof serviceUrl === 'string' && serviceUrl.trim()) {
const proxyPath = `/${proxyPrefix}${serviceName}`
const isWs = serviceUrl.startsWith('ws://') || serviceUrl.startsWith('wss://')
// 生成代理配置
proxyConfig[proxyPath] = {
target: serviceUrl,
changeOrigin: true,
ws: isWs,
rewrite: (path: string): string => path.replace(new RegExp(`^/${proxyPrefix}${serviceName}`), ''),
}
// 生成代理映射
proxyMapping[serviceName] = {
path: proxyPath,
rawPath: serviceUrl,
}
}
})
return { proxyConfig, proxyMapping }
}
function generateDtsFile(
mapping: ProxyMapping,
outputPath: string,
envName: string,
) {
try {
const serviceNames = Object.keys(mapping).map(name => `'${name}'`).join(' | ')
const serviceNameType = serviceNames || 'never'
const dtsContent = `/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by auto-proxy
// biome-ignore lint: disable
export {}
type serviceName = ${serviceNameType}
declare global {
const ${envName}: {
[K in serviceName]: {
path: string
rawPath: string
}
}
}
`
const dir = dirname(outputPath)
if (dir) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(outputPath, dtsContent, 'utf-8')
}
catch (error) {
console.error(`[auto-proxy] 生成 d.ts 文件失败:`, (error as Error).message)
}
}

View File

@ -1,16 +1,17 @@
import UnoCSS from '@unocss/vite' import UnoCSS from '@unocss/vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import viteCompression from 'vite-plugin-compression'
import Icons from 'unplugin-icons/vite'
import { FileSystemIconLoader } from 'unplugin-icons/loaders' import { FileSystemIconLoader } from 'unplugin-icons/loaders'
// https://github.com/antfu/unplugin-icons // https://github.com/antfu/unplugin-icons
import IconsResolver from 'unplugin-icons/resolver' import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
import viteCompression from 'vite-plugin-compression'
import VueDevTools from 'vite-plugin-vue-devtools'
import AutoProxy from './autoProxy'
import { serviceConfig } from '../service.config'
/** /**
* @description: vite插件配置 * @description: vite插件配置
* @param {*} env - * @param {*} env -
@ -21,13 +22,29 @@ export function createVitePlugins(env: ImportMetaEnv) {
// support vue // support vue
vue(), vue(),
vueJsx(), vueJsx(),
VueDevTools(),
// support unocss // support unocss
UnoCSS(), UnoCSS(),
// auto import api of lib // auto import api of lib
AutoImport({ AutoImport({
imports: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'], imports: [
'vue',
'vue-router',
'pinia',
'@vueuse/core',
'vue-i18n',
{
'naive-ui': [
'useDialog',
'useMessage',
'useNotification',
'useLoadingBar',
'useModal',
],
},
],
include: [ include: [
/\.[tj]sx?$/, /\.[tj]sx?$/,
/\.vue$/, /\.vue$/,
@ -62,6 +79,12 @@ export function createVitePlugins(env: ImportMetaEnv) {
), ),
}, },
}), }),
AutoProxy({
enableProxy: env.VITE_HTTP_PROXY === 'Y',
serviceConfig,
dts: 'src/typings/auto-proxy.d.ts',
}),
] ]
// use compression // use compression
if (env.VITE_BUILD_COMPRESS === 'Y') { if (env.VITE_BUILD_COMPRESS === 'Y') {

View File

@ -1,32 +0,0 @@
import type { ProxyOptions } from 'vite'
import { mapEntries } from 'radash'
export function generateProxyPattern(envConfig: Record<string, string>) {
return mapEntries(envConfig, (key, value) => {
return [
key,
{
value,
proxy: `/proxy-${key}`,
},
]
})
}
/**
* @description: vite代理字段
* @param {*} envConfig -
*/
export function createViteProxy(envConfig: Record<string, string>) {
const proxyMap = generateProxyPattern(envConfig)
return mapEntries(proxyMap, (key, value) => {
return [
value.proxy,
{
target: value.value,
changeOrigin: true,
rewrite: (path: string) => path.replace(new RegExp(`^${value.proxy}`), ''),
},
]
}) as Record<string, string | ProxyOptions>
}

View File

@ -0,0 +1,8 @@
services:
nova-admin:
build:
context: .
dockerfile: ./docker/dockerfile.product
container_name: nova-admin
ports:
- 80:80

23
docker/dockerfile.product Normal file
View File

@ -0,0 +1,23 @@
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS builder
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM nginx:1.23.1-alpine
WORKDIR /www
COPY --from=builder /app/dist/ .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@ -2,4 +2,21 @@
import antfu from '@antfu/eslint-config' import antfu from '@antfu/eslint-config'
// https://github.com/antfu/eslint-config // https://github.com/antfu/eslint-config
export default antfu() export default antfu(
{
typescript: {
overrides: {
'perfectionist/sort-exports': 'off',
'perfectionist/sort-imports': 'off',
'ts/no-unused-expressions': ['error', { allowShortCircuit: true }],
},
},
vue: {
overrides: {
'vue/no-unused-refs': 'off', // 暂时关闭等待vue-lint的分支合并
'vue/no-reserved-component-names': 'off',
'vue/component-definition-name-casing': 'off',
},
},
},
)

View File

@ -9,7 +9,6 @@
</head> </head>
<body> <body>
<div id="appLoading"></div>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View File

@ -3,14 +3,18 @@
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"close": "Closure", "close": "Closure",
"reload": "Refresh" "reload": "Refresh",
"choose": "Choose",
"navigate": "Navigate",
"inputPlaceholder": "please enter",
"selectPlaceholder": "please choose"
}, },
"app": { "app": {
"loginOut": "Login out", "loginOut": "Login out",
"loginOutContent": "Confirm to log out of current account?", "loginOutContent": "Confirm to log out of current account?",
"loginOutTitle": "Sign out", "loginOutTitle": "Sign out",
"userCenter": "Personal center", "userCenter": "Personal center",
"lignt": "Light", "light": "Light",
"dark": "Dark", "dark": "Dark",
"system": "System", "system": "System",
"backTop": "Back to top", "backTop": "Back to top",
@ -38,6 +42,7 @@
"themeSetting": "Theme settings", "themeSetting": "Theme settings",
"todos": "Todos", "todos": "Todos",
"toggleFullScreen": "Toggle full screen", "toggleFullScreen": "Toggle full screen",
"togglContentFullScreen": "Toggle content full screen",
"topProgress": "Top progress", "topProgress": "Top progress",
"transitionFadeBottom": "Bottom fade", "transitionFadeBottom": "Bottom fade",
"transitionFadeScale": "Scale fade", "transitionFadeScale": "Scale fade",
@ -54,8 +59,12 @@
"backHome": "Back to the homepage", "backHome": "Back to the homepage",
"getRouteError": "Failed to obtain route, please try again later.", "getRouteError": "Failed to obtain route, please try again later.",
"layoutSetting": "Layout settings", "layoutSetting": "Layout settings",
"leftMenu": "Left menu", "verticalLayout": "Vertical layout",
"topMenu": "Top menu" "horizontalLayout": "Horizontal layout",
"twoColumnLayout": "Two column layout",
"mixedTwoColumnLayout": "Mixed two column layout",
"sidebarLayout": "Sidebar layout",
"mixedSidebarLayout": "Mixed sidebar layout"
}, },
"login": { "login": {
"signInTitle": "Login", "signInTitle": "Login",
@ -83,34 +92,33 @@
"route": { "route": {
"appRoot": "Home", "appRoot": "Home",
"cardList": "Card list", "cardList": "Card list",
"draggableList": "Draggable list",
"commonList": "Common list", "commonList": "Common list",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"demo": "Function example", "demo": "Function example",
"fetch": "Request example", "fetch": "Request example",
"list": "List", "list": "List",
"monitor": "Monitoring", "monitor": "Monitoring",
"test": "Multi-level menu", "multi": "Multi-level menu",
"test2": "Multi-level menu subpage", "multi2": "Multi-level menu subpage",
"test2Detail": "Details page of multi-level menu", "multi2Detail": "Details page of multi-level menu",
"test3": "multi-level menu", "multi3": "multi-level menu",
"test4": "Multi-level menu 3-1", "multi4": "Multi-level menu 3-1",
"workbench": "Workbench", "workbench": "Workbench",
"QRCode": "QR code", "QRCode": "QR code",
"about": "About", "about": "About",
"clipboard": "Clipboard", "clipboard": "Clipboard",
"demo403": "403",
"demo404": "404",
"demo500": "500",
"dictionarySetting": "Dictionary settings", "dictionarySetting": "Dictionary settings",
"docments": "Document", "documents": "Document",
"docmentsVite": "Vite", "documentsVite": "Vite",
"docmentsVue": "Vue", "documentsVue": "Vue",
"docmentsVueuse": "VueUse (external link)", "documentsVueuse": "VueUse (external link)",
"documentsNova": "Nova docs",
"documentsPublic": "Public page (external link)",
"echarts": "Echarts", "echarts": "Echarts",
"editor": "Editor", "editor": "Editor",
"editorMd": "MarkDown editor", "editorMd": "MarkDown editor",
"editorRich": "Rich text editor", "editorRich": "Rich text editor",
"error": "Exception page",
"icons": "Icon", "icons": "Icon",
"justSuper": "Supervisible", "justSuper": "Supervisible",
"map": "Map", "map": "Map",
@ -119,7 +127,9 @@
"permissionDemo": "Permissions example", "permissionDemo": "Permissions example",
"setting": "System settings", "setting": "System settings",
"userCenter": "Personal Center", "userCenter": "Personal Center",
"accountSetting": "User settings" "accountSetting": "User settings",
"cascader": "Administrative region selection",
"dict": "Dictionary example"
}, },
"http": { "http": {
"400": "Syntax error in the request", "400": "Syntax error in the request",
@ -139,7 +149,15 @@
"components": { "components": {
"iconSelector": { "iconSelector": {
"inputPlaceholder": "Select target icon", "inputPlaceholder": "Select target icon",
"searchPlaceholder": "Search icon" "searchPlaceholder": "Search icon",
"clearIcon": "Clear icon",
"selectorTitle": "Icon selection"
},
"copyText": {
"message": "Copied successfully",
"tooltip": "Copy",
"unsupportedError": "Your browser does not support Clipboard API",
"unpermittedError": "Crrently not permitted to use Clipboard API"
} }
} }
} }

View File

@ -3,19 +3,24 @@
"confirm": "确认", "confirm": "确认",
"cancel": "取消", "cancel": "取消",
"reload": "刷新", "reload": "刷新",
"close": "关闭" "close": "关闭",
"choose": "选择",
"navigate": "切换",
"inputPlaceholder": "请输入",
"selectPlaceholder": "请选择"
}, },
"app": { "app": {
"loginOut": "退出登录", "loginOut": "退出登录",
"loginOutTitle": "退出登录", "loginOutTitle": "退出登录",
"loginOutContent": "确认退出当前账号?", "loginOutContent": "确认退出当前账号?",
"userCenter": "个人中心", "userCenter": "个人中心",
"lignt": "浅色", "light": "浅色",
"dark": "深色", "dark": "深色",
"system": "跟随系统", "system": "跟随系统",
"backTop": "返回顶部", "backTop": "返回顶部",
"toggleSider": "切换侧边栏", "toggleSider": "切换侧边栏",
"toggleFullScreen": "切换全屏", "toggleFullScreen": "切换全屏",
"togglContentFullScreen": "切换内容全屏",
"notificationsTips": "消息通知", "notificationsTips": "消息通知",
"notifications": "通知", "notifications": "通知",
"messages": "消息", "messages": "消息",
@ -54,8 +59,12 @@
"backHome": "回到首页", "backHome": "回到首页",
"getRouteError": "获取路由失败,请稍后再试", "getRouteError": "获取路由失败,请稍后再试",
"layoutSetting": "布局设置", "layoutSetting": "布局设置",
"leftMenu": "左侧菜单", "verticalLayout": "竖向布局",
"topMenu": "顶部菜单" "horizontalLayout": "横向布局",
"twoColumnLayout": "双栏布局",
"mixedTwoColumnLayout": "混合双栏布局",
"sidebarLayout": "侧边栏布局",
"mixedSidebarLayout": "双栏布局"
}, },
"http": { "http": {
"400": "请求出现语法错误", "400": "请求出现语法错误",
@ -74,8 +83,16 @@
}, },
"components": { "components": {
"iconSelector": { "iconSelector": {
"selectorTitle": "图标选择",
"inputPlaceholder": "选择目标图标", "inputPlaceholder": "选择目标图标",
"searchPlaceholder": "搜索图标" "searchPlaceholder": "搜索图标",
"clearIcon": "清除图标"
},
"copyText": {
"tooltip": "复制",
"message": "复制成功",
"unsupportedError": "您的浏览器不支持剪贴板API",
"unpermittedError": "目前不允许使用剪贴板API"
} }
}, },
"login": { "login": {
@ -106,14 +123,15 @@
"dashboard": "仪表盘", "dashboard": "仪表盘",
"workbench": "工作台", "workbench": "工作台",
"monitor": "监控页", "monitor": "监控页",
"test": "多级菜单演示", "multi": "多级菜单演示",
"test2": "多级菜单子页", "multi2": "多级菜单子页",
"test2Detail": "多级菜单的详情页", "multi2Detail": "多级菜单的详情页",
"test3": "多级菜单", "multi3": "多级菜单",
"test4": "多级菜单3-1", "multi4": "多级菜单3-1",
"list": "列表页", "list": "列表页",
"commonList": "常用列表", "commonList": "常用列表",
"cardList": "卡片列表", "cardList": "卡片列表",
"draggableList": "拖拽列表",
"demo": "功能示例", "demo": "功能示例",
"fetch": "请求示例", "fetch": "请求示例",
"echarts": "Echarts示例", "echarts": "Echarts示例",
@ -124,22 +142,22 @@
"clipboard": "剪贴板", "clipboard": "剪贴板",
"icons": "图标", "icons": "图标",
"QRCode": "二维码", "QRCode": "二维码",
"docments": "文档", "documents": "文档",
"docmentsVue": "Vue", "documentsVue": "Vue",
"docmentsVite": "Vite", "documentsVite": "Vite",
"docmentsVueuse": "VueUse外链", "documentsVueuse": "VueUse外链",
"documentsNova": "Nova 文档",
"documentsPublic": "公共示例页(外链)",
"permission": "权限", "permission": "权限",
"permissionDemo": "权限示例", "permissionDemo": "权限示例",
"justSuper": "super可见", "justSuper": "super可见",
"error": "异常页",
"demo403": "403",
"demo404": "404",
"demo500": "500",
"setting": "系统设置", "setting": "系统设置",
"accountSetting": "用户设置", "accountSetting": "用户设置",
"dictionarySetting": "字典设置", "dictionarySetting": "字典设置",
"menuSetting": "菜单设置", "menuSetting": "菜单设置",
"userCenter": "个人中心", "userCenter": "个人中心",
"about": "关于" "about": "关于",
"cascader": "省市区联动",
"dict": "字典示例"
} }
} }

View File

@ -1,17 +0,0 @@
[build]
publish = "dist"
command = "vite build --mode prod"
[build.environment]
NODE_VERSION = "20"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/manifest.webmanifest"
[headers.values]
Content-Type = "application/manifest+json"

66
nginx.conf Normal file
View File

@ -0,0 +1,66 @@
server {
listen 80;
listen [::]:80;
# 启用 gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
gzip_disable "MSIE [1-6]\.";
# 设定 MIME types
include /etc/nginx/mime.types;
# 基本安全设定
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
# 增加伺服器效能的配置
client_max_body_size 100M;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
location / {
root /www;
index index.html;
try_files $uri $uri/ /index.html;
# 设定快取控制
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# 动态内容不快取
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
expires -1;
}
# 错误处理
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_intercept_errors on;
# 基本的代理设定
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@ -1,9 +1,9 @@
{ {
"name": "nova-admin", "name": "nova-admin",
"type": "module", "type": "module",
"version": "0.9.0", "version": "0.9.18",
"private": true, "private": true,
"description": "", "description": "a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI.",
"author": { "author": {
"name": "chansee97", "name": "chansee97",
"email": "chen.dev@foxmail.com", "email": "chen.dev@foxmail.com",
@ -38,64 +38,58 @@
], ],
"scripts": { "scripts": {
"dev": "vite --mode dev --port 9980", "dev": "vite --mode dev --port 9980",
"dev:test": "vite --mode test", "dev:prod": "vite --mode production",
"dev:prod": "vite --mode prod", "build": "vite build",
"build": "vue-tsc --noEmit && vite build --mode prod", "build:dev": "vite build --mode dev",
"build:dev": "vue-tsc --noEmit && vite build --mode dev",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"preview": "vite preview --port 9981", "preview": "vite preview --port 9981",
"lint": "eslint .", "lint": "eslint . && vue-tsc --noEmit",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"lint:check": "npx @eslint/config-inspector", "lint:check": "npx @eslint/config-inspector",
"sizecheck": "npx vite-bundle-visualizer" "sizecheck": "npx vite-bundle-visualizer"
}, },
"dependencies": { "dependencies": {
"@alova/scene-vue": "^1.4.6", "@vueuse/core": "^13.6.0",
"@tinymce/tinymce-vue": "^5.1.1", "alova": "^3.3.4",
"@vueuse/core": "^10.9.0",
"alova": "^2.19.0",
"colord": "^2.9.3", "colord": "^2.9.3",
"echarts": "^5.5.0", "echarts": "^5.6.0",
"md-editor-v3": "^4.11.3", "md-editor-v3": "^5.6.1",
"performant-array-to-tree": "^1.11.0", "pinia": "^3.0.3",
"pinia": "^2.1.7", "pinia-plugin-persistedstate": "^4.4.1",
"pinia-plugin-persist": "^1.0.0", "pro-naive-ui": "^2.4.3",
"qs": "^6.12.0", "quill": "^2.0.3",
"radash": "^12.1.0", "radash": "^12.1.1",
"vue": "^3.4.21", "vue": "^3.5.18",
"vue-i18n": "^9.11.1", "vue-draggable-plus": "^0.6.0",
"vue-router": "^4.3.0" "vue-i18n": "^11.1.11",
"vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^2.13.3", "@antfu/eslint-config": "^5.0.0",
"@iconify-json/icon-park-outline": "^1.1.15", "@iconify-json/icon-park-outline": "^1.2.2",
"@iconify/vue": "^4.1.1", "@iconify/vue": "^5.0.0",
"@types/node": "^20.12.7", "@types/node": "^24.1.0",
"@types/qs": "^6.9.14", "@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue-jsx": "^5.0.1",
"@vitejs/plugin-vue-jsx": "^3.1.0", "eslint": "^9.29.0",
"eslint": "^9.0.0", "lint-staged": "^16.1.2",
"lint-staged": "^15.2.2", "naive-ui": "^2.42.0",
"naive-ui": "^2.38.1", "sass": "^1.89.2",
"sass": "^1.75.0", "simple-git-hooks": "^2.13.1",
"simple-git-hooks": "^2.11.1", "typescript": "^5.8.3",
"typescript": "^5.4.5", "unocss": "^66.3.3",
"unocss": "^0.59.1", "unplugin-auto-import": "^19.3.0",
"unplugin-auto-import": "^0.17.5", "unplugin-icons": "^22.2.0",
"unplugin-icons": "^0.18.5", "unplugin-vue-components": "^28.8.0",
"unplugin-vue-components": "^0.26.0", "vite": "^7.0.6",
"vite": "^5.2.8", "vite-bundle-visualizer": "^1.2.1",
"vite-bundle-visualizer": "^1.1.0",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vue-tsc": "^2.0.12" "vite-plugin-vue-devtools": "8.0.0",
"vue-tsc": "^3.0.5"
}, },
"simple-git-hooks": { "simple-git-hooks": {
"pre-commit": "pnpm lint-staged" "pre-commit": "pnpm lint-staged"
}, },
"lint-staged": { "lint-staged": {
"*": "eslint --fix" "*": "eslint --fix"
},
"volta": {
"node": "20.12.2"
} }
} }

6
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,6 @@
ignoredBuiltDependencies:
- '@parcel/watcher'
- esbuild
- simple-git-hooks
- vue-demi
- unrs-resolver

View File

@ -1,12 +1,9 @@
/** 不同请求服务的环境配置 */ /** 不同请求服务的环境配置 */
export const serviceConfig: Record<ServiceEnvType, Record<string, string>> = { export const serviceConfig: Record<ServiceEnvType, Record<string, string>> = {
dev: { dev: {
url: 'https://mock.apifox.com/m1/4071143-0-default', url: 'http://localhost:3000',
}, },
test: { production: {
url: 'https://mock.apifox.com/m1/4071143-0-default', url: 'https://mock.apifox.cn/m1/4071143-0-default',
},
prod: {
url: 'https://mock.apifox.com/m1/4071143-0-default',
}, },
} }

View File

@ -1,24 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { darkTheme } from 'naive-ui' import AppMain from './AppMain.vue'
import { useAppStore } from './store' import AppLoading from './components/common/AppLoading.vue'
import { naiveI18nOptions } from '@/utils'
const appStore = useAppStore() // 使 Suspense
const naiveLocale = computed(() => {
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
},
)
</script> </script>
<template> <template>
<n-config-provider <Suspense>
class="wh-full" inline-theme-disabled :theme="appStore.colorMode === 'dark' ? darkTheme : null" <!-- 异步组件 -->
:locale="naiveLocale.locale" :date-locale="naiveLocale.dateLocale" :theme-overrides="appStore.theme" <AppMain />
>
<naive-provider> <!-- 加载状态 -->
<router-view /> <template #fallback>
<Watermark :show-watermark="appStore.showWatermark" /> <AppLoading />
</naive-provider> </template>
</n-config-provider> </Suspense>
</template> </template>

57
src/AppMain.vue Normal file
View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import type { App } from 'vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
import { naiveI18nOptions } from '@/utils'
import { darkTheme } from 'naive-ui'
import { useAppStore } from './store'
// Promise -
const initializationPromise = (async () => {
//
const app = getCurrentInstance()?.appContext.app
if (!app) {
throw new Error('Failed to get app instance')
}
// Pinia
await installPinia(app)
// Vue-router
await installRouter(app)
// /
const modules = import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
eager: true,
})
Object.values(modules).forEach(module => app.use(module))
return true
})()
// - 使 setup
await initializationPromise
const appStore = useAppStore()
const naiveLocale = computed(() => {
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
})
</script>
<template>
<n-config-provider
class="wh-full"
inline-theme-disabled
:theme="appStore.colorMode === 'dark' ? darkTheme : null"
:locale="naiveLocale.locale"
:date-locale="naiveLocale.dateLocale"
:theme-overrides="appStore.theme"
>
<naive-provider>
<router-view />
<Watermark :show-watermark="appStore.showWatermark" />
</naive-provider>
</n-config-provider>
</template>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -2,6 +2,7 @@
</script> </script>
<template> <template>
<naive-provider>
<div id="loading-container"> <div id="loading-container">
<div class="boxes"> <div class="boxes">
<div class="box"> <div class="box">
@ -30,6 +31,7 @@
</div> </div>
</div> </div>
</div> </div>
</naive-provider>
</template> </template>
<style scoped> <style scoped>

View File

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { NFlex } from 'naive-ui'
import { useAppStore } from '@/store' import { useAppStore } from '@/store'
import IconSun from '~icons/icon-park-outline/sun-one'
import IconMoon from '~icons/icon-park-outline/moon'
import IconAuto from '~icons/icon-park-outline/laptop-computer' import IconAuto from '~icons/icon-park-outline/laptop-computer'
import IconMoon from '~icons/icon-park-outline/moon'
import IconSun from '~icons/icon-park-outline/sun-one'
import { NFlex } from 'naive-ui'
const { t } = useI18n() const { t } = useI18n()
@ -12,7 +12,7 @@ const appStore = useAppStore()
const options = computed(() => { const options = computed(() => {
return [ return [
{ {
label: t('app.lignt'), label: t('app.light'),
value: 'light', value: 'light',
icon: IconSun, icon: IconSun,
}, },

View File

@ -1,36 +0,0 @@
<script setup lang="ts">
defineProps<{
/** 异常类型 403 404 500 */
type: '403' | '404' | '500'
}>()
const router = useRouter()
</script>
<template>
<div class="flex-col-center h-full">
<img
v-if="type === '403'"
src="@/assets/svg/error-403.svg"
alt=""
class="w-1/3"
>
<img
v-if="type === '404'"
src="@/assets/svg/error-404.svg"
alt=""
class="w-1/3"
>
<img
v-if="type === '500'"
src="@/assets/svg/error-500.svg"
alt=""
class="w-1/3"
>
<n-button
type="primary"
@click="router.push('/')"
>
{{ $t('app.backHome') }}
</n-button>
</div>
</template>

View File

@ -3,14 +3,14 @@ interface Props {
message: string message: string
} }
const props = defineProps<Props>() const { message } = defineProps<Props>()
</script> </script>
<template> <template>
<n-tooltip :show-arrow="false" trigger="hover"> <n-tooltip :show-arrow="false" trigger="hover">
<template #trigger> <template #trigger>
<icon-park-outline-help /> <icon-park-outline-help class="op-50 cursor-help" />
</template> </template>
{{ props.message }} {{ message }}
</n-tooltip> </n-tooltip>
</template> </template>

View File

@ -0,0 +1,188 @@
<script setup lang="ts">
interface Props {
disabled?: boolean
}
const {
disabled = false,
} = defineProps<Props>()
interface IconList {
prefix: string
icons: string[]
title: string
total: number
categories?: Record<string, string[]>
uncategorized?: string[]
}
const value = defineModel('value', { type: String })
// https://icon-sets.iconify.design/
const nameList = ['icon-park-outline', 'carbon', 'ant-design']
//
async function fetchIconList(name: string): Promise<IconList> {
return await fetch(`https://api.iconify.design/collection?prefix=${name}`).then(res => res.json())
}
//
async function fetchIconAllList(nameList: string[]) {
//
const targets = await Promise.all(nameList.map(fetchIconList))
//
const iconList = targets.map((item) => {
const icons = [
...(item.categories ? Object.values(item.categories).flat() : []),
...(item.uncategorized ? Object.values(item.uncategorized).flat() : []),
]
return { ...item, icons }
})
//
const svgNames = Object.keys(import.meta.glob('@/assets/svg-icons/*.svg')).map(
path => path.split('/').pop()?.replace('.svg', ''),
).filter(Boolean) as string[] // undefined string[]
//
iconList.unshift({
prefix: 'local',
title: 'Local Icons',
icons: svgNames,
total: svgNames.length,
uncategorized: svgNames,
})
return iconList
}
const iconList = shallowRef<IconList[]>([])
onMounted(async () => {
iconList.value = await fetchIconAllList(nameList)
})
// tab
const currentTab = shallowRef(0)
// tag
const currentTag = shallowRef('')
//
const searchValue = ref('')
//
const currentPage = shallowRef(1)
// tab
function handleChangeTab(index: number) {
currentTab.value = index
currentTag.value = ''
currentPage.value = 1
}
// tag
function handleSelectIconTag(icon: string) {
currentTag.value = currentTag.value === icon ? '' : icon
currentPage.value = 1
}
//
const icons = computed(() => {
if (!iconList.value[currentTab.value])
return []
const hasTag = !!currentTag.value
return hasTag
? iconList.value[currentTab.value]?.categories?.[currentTag.value] || [] // 使
: iconList.value[currentTab.value].icons || []
})
//
const filteredIcons = computed(() => {
return icons.value?.filter(i => i.includes(searchValue.value)) || []
})
//
const visibleIcons = computed(() => {
return filteredIcons.value.slice((currentPage.value - 1) * 200, currentPage.value * 200)
})
const showModal = ref(false)
//
function handleSelectIcon(icon: string) {
value.value = icon
showModal.value = false
}
//
function clearIcon() {
value.value = ''
showModal.value = false
}
</script>
<template>
<n-input-group disabled>
<n-button v-if="value" :disabled="disabled" type="primary">
<template #icon>
<nova-icon :icon="value" />
</template>
</n-button>
<n-input :value="value" readonly :placeholder="$t('components.iconSelector.inputPlaceholder')" />
<n-button type="primary" ghost :disabled="disabled" @click="showModal = true">
{{ $t('common.choose') }}
</n-button>
</n-input-group>
<n-modal
v-model:show="showModal" preset="card" :title="$t('components.iconSelector.selectorTitle')" size="small" class="w-800px" :bordered="false"
>
<template #header-extra>
<n-button type="warning" size="small" ghost @click="clearIcon">
{{ $t('components.iconSelector.clearIcon') }}
</n-button>
</template>
<n-tabs :value="currentTab" type="line" animated placement="left" @update:value="handleChangeTab">
<n-tab-pane v-for="(list, index) in iconList" :key="list.prefix" :name="index" :tab="list.title">
<n-flex vertical>
<n-flex size="small">
<n-tag
v-for="(_v, k) in list.categories" :key="k"
:checked="currentTag === k" round checkable size="small"
@update:checked="handleSelectIconTag(k)"
>
{{ k }}
</n-tag>
</n-flex>
<n-input
v-model:value="searchValue" type="text" clearable
:placeholder="$t('components.iconSelector.searchPlaceholder')"
/>
<div>
<n-flex :size="2">
<n-el
v-for="(icon) in visibleIcons" :key="icon"
class="hover:(text-[var(--primary-color)] ring-1) ring-[var(--primary-color)] p-1 rounded flex-center"
:title="`${list.prefix}:${icon}`"
@click="handleSelectIcon(`${list.prefix}:${icon}`)"
>
<nova-icon :icon="`${list.prefix}:${icon}`" :size="24" />
</n-el>
<n-empty v-if="visibleIcons.length === 0" class="w-full" />
</n-flex>
</div>
<n-flex justify="center">
<n-pagination
v-model:page="currentPage"
:item-count="filteredIcons.length"
:page-size="200"
/>
</n-flex>
</n-flex>
</n-tab-pane>
</n-tabs>
</n-modal>
</template>

View File

@ -1,35 +0,0 @@
export const icons: string[] = [
'icon-park-outline:ad-product',
'icon-park-outline:all-application',
'icon-park-outline:hamburger-button',
'icon-park-outline:setting',
'icon-park-outline:add-one',
'icon-park-outline:reduce-one',
'icon-park-outline:close-one',
'icon-park-outline:help',
'icon-park-outline:info',
'icon-park-outline:grid-four',
'icon-park-outline:key-two',
'icon-park-outline:write',
'icon-park-outline:fire',
'icon-park-outline:memory-card-one',
'icon-park-outline:coupon',
'icon-park-outline:ticket-one',
'icon-park-outline:pay-code-two',
'icon-park-outline:wallet-one',
'icon-park-outline:gift',
'icon-park-outline:mail',
'icon-park-outline:log',
'icon-park-outline:people',
'icon-park-outline:alarm-clock',
'ic:baseline-filter-1',
'ic:baseline-filter-2',
'ic:baseline-filter-3',
'ic:baseline-filter-4',
'ic:baseline-filter-5',
'ic:baseline-filter-6',
'ic:baseline-filter-7',
'ic:baseline-filter-8',
'ic:baseline-filter-9',
'ic:baseline-filter-9-plus',
]

View File

@ -1,51 +0,0 @@
<script setup lang="ts">
import { icons } from './icons'
interface Props {
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
const value = defineModel('value', { type: String })
const searchValue = ref('')
const showPopover = ref(false)
const { t } = useI18n()
const iconList = computed(() => icons.filter(item => item.includes(searchValue.value)))
function handleSelectIcon(icon: string) {
value.value = icon
showPopover.value = false
}
</script>
<template>
<n-popover v-model:show="showPopover" placement="bottom" trigger="click" :disabled="props.disabled">
<template #trigger>
<n-input :value="value" readonly :placeholder="t('components.iconSelector.inputPlaceholder')">
<template #suffix>
<nova-icon :icon="value" />
</template>
</n-input>
</template>
<template #header>
<n-input v-model:value="searchValue" type="text" :placeholder="t('components.iconSelector.searchPlaceholder')" />
</template>
<div class="w-400px">
<div v-if="iconList.length > 0" class="grid grid-cols-9 h-auto overflow-auto gap-1">
<div
v-for="(item, index) in iconList" :key="index" class="border border-gray-200 m-2px p-5px flex-center"
@click="handleSelectIcon(item)"
>
<nova-icon :icon="item" :size="24" />
</div>
</div>
<n-empty v-else class="w-full" />
</div>
</n-popover>
</template>
<style scoped></style>

View File

@ -11,20 +11,36 @@ interface iconPorps {
/* 图标深度 */ /* 图标深度 */
depth?: 1 | 2 | 3 | 4 | 5 depth?: 1 | 2 | 3 | 4 | 5
} }
const props = withDefaults(defineProps<iconPorps>(), { const { size = 18, icon } = defineProps<iconPorps>()
size: 18,
const isLocal = computed(() => {
return icon && icon.startsWith('local:')
}) })
function getLocalIcon(icon: string) {
const svgName = icon.replace('local:', '')
const svg = import.meta.glob<string>('@/assets/svg-icons/*.svg', {
query: '?raw',
import: 'default',
eager: true,
})
return svg[`/src/assets/svg-icons/${svgName}.svg`]
}
</script> </script>
<template> <template>
<n-icon <n-icon
v-if="props.icon" v-if="icon"
:size="props.size" :size="size"
:depth="props.depth" :depth="depth"
:color="props.color" :color="color"
> >
<Icon :icon="props.icon" /> <template v-if="isLocal">
<i v-html="getLocalIcon(icon)" />
</template>
<template v-else>
<Icon :icon="icon" />
</template>
</n-icon> </n-icon>
</template> </template>
<style scoped></style>

View File

@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps({ interface Props {
count: { count?: number
type: Number, }
default: 0, const {
}, count = 0,
}) } = defineProps<Props>()
const emit = defineEmits(['change'])
const emit = defineEmits<{
change: [page: number, pageSize: number] //
}>()
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const displayOrder: Array<'pages' | 'size-picker' | 'quick-jumper'> = ['size-picker', 'pages'] const displayOrder: Array<'pages' | 'size-picker' | 'quick-jumper'> = ['size-picker', 'pages']
@ -17,10 +21,11 @@ function changePage() {
<template> <template>
<n-pagination <n-pagination
v-if="props.count > 0" v-if="count > 0"
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:item-count="props.count" :page-sizes="[10, 20, 30, 50]"
:item-count="count"
:display-order="displayOrder" :display-order="displayOrder"
show-size-picker show-size-picker
@update-page="changePage" @update-page="changePage"

View File

@ -3,16 +3,15 @@ interface Props {
showWatermark: boolean showWatermark: boolean
text?: string text?: string
} }
const props = withDefaults(defineProps<Props>(), { const {
showWatermark: false, text = 'Watermark',
text: 'Watermark', } = defineProps<Props>()
})
</script> </script>
<template> <template>
<n-watermark <n-watermark
v-if="props.showWatermark" v-if="showWatermark"
:content="props.text" :content="text"
cross cross
fullscreen fullscreen
:font-size="16" :font-size="16"

View File

@ -1,17 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ interface Props {
maxLength?: string maxLength?: string
}>() }
const modelValue = defineModel<string>() const { maxLength } = defineProps<Props>()
const modelValue = defineModel<string>('value')
</script> </script>
<template> <template>
<div v-if="modelValue" class="inline-flex items-center gap-0.5em"> <div v-if="modelValue" class="inline-flex items-center gap-0.5em">
<n-ellipsis :style="{ 'max-width': props.maxLength || '12em' }"> <n-ellipsis :style="{ 'max-width': maxLength || '12em' }">
{{ modelValue }} {{ modelValue }}
</n-ellipsis> </n-ellipsis>
<n-tooltip trigger="hover">
<template #trigger>
<span v-copy="modelValue" class="cursor-pointer"> <span v-copy="modelValue" class="cursor-pointer">
<icon-park-outline-copy /> <icon-park-outline-copy />
</span> </span>
</template>
{{ $t('components.copyText.tooltip') }}
</n-tooltip>
</div> </div>
</template> </template>

View File

@ -1,26 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ToolbarNames } from 'md-editor-v3' import type { ToolbarNames } from 'md-editor-v3'
import { MdEditor } from 'md-editor-v3'
// https://imzbf.github.io/md-editor-v3/zh-CN/docs
import 'md-editor-v3/lib/style.css'
import { useAppStore } from '@/store' import { useAppStore } from '@/store'
const props = defineProps<{ import { MdEditor } from 'md-editor-v3'
modelValue: string // https://imzbf.github.io/md-editor-v3/zh-CN/docs
}>() import 'md-editor-v3/lib/style.css'
const emit = defineEmits(['update:modelValue']) const model = defineModel<string>()
const appStore = useAppStore() const appStore = useAppStore()
const data = useVModel(props, 'modelValue', emit)
const theme = computed(() => {
return appStore.colorMode ? 'dark' : 'light'
})
const toolbarsExclude: ToolbarNames[] = [ const toolbarsExclude: ToolbarNames[] = [
'mermaid', 'mermaid',
'katex', 'katex',
@ -32,7 +22,7 @@ const toolbarsExclude: ToolbarNames[] = [
<template> <template>
<MdEditor <MdEditor
v-model="data" :theme="theme" :toolbars-exclude="toolbarsExclude" v-model="model" :theme="appStore.colorMode" :toolbars-exclude="toolbarsExclude"
/> />
</template> </template>

View File

@ -1,77 +1,107 @@
<script setup lang="ts"> <script setup lang="ts">
import Editor from '@tinymce/tinymce-vue' import Quill from 'quill'
import { useTemplateRef } from 'vue'
import 'quill/dist/quill.snow.css'
const props = defineProps<{ defineOptions({
modelValue: string name: 'RichTextEditor',
}>()
const emit = defineEmits(['update:modelValue'])
const data = useVModel(props, 'modelValue', emit)
function imagesUploadHandler(blobInfo: any, _progress: number) {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', blobInfo.blob())
fetch('www.example.com/upload', {
method: 'POST',
body: formData,
}) })
.then((response) => {
if (!response.ok)
throw new Error('上传失败')
// const { disabled } = defineProps<Props>()
resolve('上传成功') interface Props {
}) disabled?: boolean
.catch((error) => {
//
reject(error)
})
})
} }
const initConfig = { const model = defineModel<string>()
language: 'zh_CN', //
min_height: 700, let editorInst = null
content_css: 'dark',
placeholder: '请输入内容', // textarea const editorModel = ref<string>()
branding: false,
font_formats: onMounted(() => {
'微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif;仿宋体=FangSong,serif;黑体=SimHei,sans-serif;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;', // initEditor()
plugins: })
'print preview searchreplace autolink directionality visualblocks visualchars fullscreen code codesample table charmap hr pagebreak nonbreaking anchor insertdatetime advlist lists wordcount textpattern autosave emoticons', // axupimgs indent2em
const editorRef = useTemplateRef<HTMLElement>('editorRef')
function initEditor() {
const options = {
modules: {
toolbar: [ toolbar: [
'fullscreen undo redo restoredraft | cut copy paste pastetext | forecolor backcolor bold italic underline strikethrough anchor | alignleft aligncenter alignright alignjustify outdent indent | bullist numlist | blockquote subscript superscript removeformat ', { header: [1, 2, 3, 4, 5, 6, false] }, //
'styleselect formatselect fontselect fontsizeselect | table emoticons charmap hr pagebreak insertdatetime selectall visualblocks | code preview | indent2em lineheight formatpainter', 'bold', //
'italic', //
'strike', // 线
{ size: ['small', false, 'large', 'huge'] }, //
{ font: [] }, //
{ color: [] }, //
{ background: [] }, //
'link', //
'image', //
'blockquote', //
'link', //
'image', //
'video', //
{ list: 'bullet' }, //
{ list: 'ordered' }, //
{ script: 'sub' }, //
{ script: 'super' }, //
{ align: [] }, //
'formula', //
'clean', // remove formatting button
], ],
paste_data_images: true, // },
//
images_upload_handler: imagesUploadHandler,
placeholder: 'Insert text here ...',
theme: 'snow',
} }
const quill = new Quill(editorRef.value!, options)
quill.on('text-change', (_delta, _oldDelta, _source) => {
editorModel.value = quill.getSemanticHTML()
})
if (disabled)
quill.enable(false)
editorInst = quill
if (model.value)
setContents(model.value)
}
function setContents(html: string) {
editorInst!.setContents(editorInst!.clipboard.convert({ html }))
}
watch(
() => model.value,
(newValue, _oldValue) => {
if (newValue && newValue !== editorModel.value) {
setContents(newValue)
}
else if (!newValue) {
setContents('')
}
},
)
watch(editorModel, (newValue, oldValue) => {
if (newValue && newValue !== oldValue)
model.value = newValue
else if (!newValue)
editorInst!.setContents([])
})
watch(
() => disabled,
(newValue, _oldValue) => {
editorInst!.enable(!newValue)
},
)
onBeforeUnmount(() => editorInst = null)
</script> </script>
<template> <template>
<div class="tinymce-boxz"> <div ref="editorRef" />
<Editor
v-model="data"
api-key="no-api"
:init="initConfig"
/>
</div>
</template> </template>
<style>
.tinymce-boxz > textarea {
display: none;
}
/* 隐藏apikey没有绑定这个域名的提示 */
.tox-notifications-container .tox-notification--warning {
display: none !important;
}
.tox.tox-tinymce {
max-width: 100%;
}
</style>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { CascaderOption } from 'naive-ui'
defineOptions({
name: 'PcaCascader',
})
// https://github.com/modood/Administrative-divisions-of-China
const pcaCode = shallowRef<CascaderOption[]>()
async function fetchPcaCode() {
return await fetch('https://cdn.jsdelivr.net/gh/modood/Administrative-divisions-of-China/dist/pca-code.json').then(res => res.json())
}
onMounted(async () => {
pcaCode.value = await fetchPcaCode()
})
</script>
<template>
<n-cascader
:options="pcaCode"
value-field="code"
label-field="name"
check-strategy="all"
filterable
clearable
/>
</template>

View File

@ -1,7 +1,3 @@
// export const genderLabels: Record<NonNullable<CommonList.GenderType>, string> = {
// 0: '女',
// 1: '男',
// }
/** Gender */ /** Gender */
export enum Gender { export enum Gender {
male, male,

View File

@ -1,4 +1,5 @@
import type { App, Directive } from 'vue' import type { App, Directive } from 'vue'
import { $t } from '@/utils'
interface CopyHTMLElement extends HTMLElement { interface CopyHTMLElement extends HTMLElement {
_copyText: string _copyText: string
@ -10,12 +11,12 @@ export function install(app: App) {
function clipboardEnable() { function clipboardEnable() {
if (!isSupported.value) { if (!isSupported.value) {
window.$message.error('Your browser does not support Clipboard API') window.$message.error($t('components.copyText.unsupportedError'))
return false return false
} }
if (permissionWrite.value !== 'granted') { if (permissionWrite.value === 'denied') {
window.$message.error('Currently not permitted to use Clipboard API') window.$message.error($t('components.copyText.unpermittedError'))
return false return false
} }
return true return true
@ -25,7 +26,7 @@ export function install(app: App) {
if (!clipboardEnable()) if (!clipboardEnable())
return return
copy(this._copyText) copy(this._copyText)
window.$message.success('复制成功') window.$message.success($t('components.copyText.message'))
} }
function updataClipboard(el: CopyHTMLElement, text: string) { function updataClipboard(el: CopyHTMLElement, text: string) {

View File

@ -4,7 +4,7 @@ import { usePermission } from '@/hooks'
export function install(app: App) { export function install(app: App) {
const { hasPermission } = usePermission() const { hasPermission } = usePermission()
function updatapermission(el: HTMLElement, permission: Auth.RoleType | Auth.RoleType[]) { function updatapermission(el: HTMLElement, permission: Entity.RoleType | Entity.RoleType[]) {
if (!permission) if (!permission)
throw new Error('v-permissson Directive with no explicit role attached') throw new Error('v-permissson Directive with no explicit role attached')
@ -12,7 +12,7 @@ export function install(app: App) {
el.parentElement?.removeChild(el) el.parentElement?.removeChild(el)
} }
const permissionDirective: Directive<HTMLElement, Auth.RoleType | Auth.RoleType[]> = { const permissionDirective: Directive<HTMLElement, Entity.RoleType | Entity.RoleType[]> = {
mounted(el, binding) { mounted(el, binding) {
updatapermission(el, binding.value) updatapermission(el, binding.value)
}, },

View File

@ -1,4 +1,3 @@
export * from './useBoolean' export * from './useBoolean'
export * from './useLoading'
export * from './useEcharts' export * from './useEcharts'
export * from './usePermission' export * from './usePermission'

View File

@ -1,6 +1,3 @@
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
// 系列类型的定义后缀都为 SeriesOption // 系列类型的定义后缀都为 SeriesOption
import type { import type {
BarSeriesOption, BarSeriesOption,
@ -8,7 +5,6 @@ import type {
PieSeriesOption, PieSeriesOption,
RadarSeriesOption, RadarSeriesOption,
} from 'echarts/charts' } from 'echarts/charts'
// 组件类型的定义后缀都为 ComponentOption // 组件类型的定义后缀都为 ComponentOption
import type { import type {
DatasetComponentOption, DatasetComponentOption,
@ -18,6 +14,9 @@ import type {
ToolboxComponentOption, ToolboxComponentOption,
TooltipComponentOption, TooltipComponentOption,
} from 'echarts/components' } from 'echarts/components'
import { useAppStore } from '@/store'
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts'
import { import {
DatasetComponent, // 数据集组件 DatasetComponent, // 数据集组件
GridComponent, GridComponent,
@ -27,10 +26,11 @@ import {
TooltipComponent, TooltipComponent,
TransformComponent, // 内置数据转换器组件 (filter, sort) TransformComponent, // 内置数据转换器组件 (filter, sort)
} from 'echarts/components' } from 'echarts/components'
import * as echarts from 'echarts/core'
import { LabelLayout, UniversalTransition } from 'echarts/features' import { LabelLayout, UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { useAppStore } from '@/store' import { useTemplateRef } from 'vue'
// 通过 ComposeOption 来组合出一个只有必须组件和图表的 Option 类型 // 通过 ComposeOption 来组合出一个只有必须组件和图表的 Option 类型
export type ECOption = echarts.ComposeOption< export type ECOption = echarts.ComposeOption<
@ -66,73 +66,61 @@ echarts.use([
/** /**
* Echarts hooks函数 * Echarts hooks函数
* @param options -
* @description * @description
*/ */
export function useEcharts(options: Ref<ECOption>) { export function useEcharts(ref: string, chartOptions: Ref<ECOption>) {
const appStore = useAppStore() const el = useTemplateRef<HTMLLIElement>(ref)
const domRef = ref<HTMLElement>() const appStore = useAppStore()
let chart: echarts.ECharts | null = null let chart: echarts.ECharts | null = null
const initialSize = { width: 0, height: 0 } const { width, height } = useElementSize(el)
const { width, height } = useElementSize(domRef, initialSize)
function canRender() { const isRendered = () => Boolean(el && chart)
return initialSize.width > 0 && initialSize.height > 0
}
function isRendered() {
return Boolean(domRef.value && chart)
}
async function render() { async function render() {
// 宽或高不存在时不渲染
if (!width || !height)
return
const chartTheme = appStore.colorMode ? 'dark' : 'light' const chartTheme = appStore.colorMode ? 'dark' : 'light'
await nextTick() await nextTick()
if (domRef.value) { if (el) {
chart = echarts.init(domRef.value, chartTheme) chart = echarts.init(el.value, chartTheme)
update(options.value) update(chartOptions.value)
} }
} }
function update(updateOptions: ECOption) { async function update(updateOptions: ECOption) {
if (isRendered()) if (isRendered()) {
chart!.setOption({ ...updateOptions, backgroundColor: 'transparent' }) chart!.setOption({ backgroundColor: 'transparent', ...updateOptions })
} }
function resize() {
chart?.resize()
} }
function destroy() { function destroy() {
chart?.dispose() chart?.dispose()
chart = null chart = null
} }
const sizeWatch = watch([width, height], async ([newWidth, newHeight]) => {
initialSize.width = newWidth watch([width, height], async ([newWidth, newHeight]) => {
initialSize.height = newHeight if (isRendered() && newWidth && newHeight)
if (newWidth === 0 && newHeight === 0) { chart?.resize()
// 节点被删除 将chart置为空
chart = null
}
if (!canRender())
return
if (isRendered())
resize()
else await render()
}) })
const OptionWatch = watch(options, (newValue) => { watch(chartOptions, (newValue) => {
update(newValue) update(newValue)
}) })
onMounted(() => {
render()
})
onUnmounted(() => { onUnmounted(() => {
sizeWatch()
OptionWatch()
destroy() destroy()
}) })
return { return {
domRef, destroy,
update,
} }
} }

View File

@ -1,15 +0,0 @@
import { useBoolean } from './useBoolean'
export function useLoading(initValue = false) {
const {
bool: loading,
setTrue: startLoading,
setFalse: endLoading,
} = useBoolean(initValue)
return {
loading,
startLoading,
endLoading,
}
}

View File

@ -1,12 +1,12 @@
import { isArray, isString } from 'radash'
import { useAuthStore } from '@/store' import { useAuthStore } from '@/store'
import { isArray, isString } from 'radash'
/** 权限判断 */ /** 权限判断 */
export function usePermission() { export function usePermission() {
const authStore = useAuthStore() const authStore = useAuthStore()
function hasPermission( function hasPermission(
permission: Auth.RoleType | Auth.RoleType[] | undefined, permission?: Entity.RoleType | Entity.RoleType[],
) { ) {
if (!permission) if (!permission)
return true return true
@ -15,13 +15,16 @@ export function usePermission() {
return false return false
const { role } = authStore.userInfo const { role } = authStore.userInfo
let has = role === 'super' // 角色为super可直接通过
let has = role.includes('super')
if (!has) { if (!has) {
if (isArray(permission)) if (isArray(permission))
has = permission.includes(role) // 角色为数组, 判断是否有交集
has = permission.some(i => role.includes(i))
if (isString(permission)) if (isString(permission))
has = permission === role // 角色为字符串, 判断是否包含
has = role.includes(permission)
} }
return has return has
} }

65
src/hooks/useTabScroll.ts Normal file
View File

@ -0,0 +1,65 @@
import type { NScrollbar } from 'naive-ui'
import { ref, type Ref, watchEffect } from 'vue'
import { throttle } from 'radash'
export function useTabScroll(currentTabPath: Ref<string>) {
const scrollbar = ref<InstanceType<typeof NScrollbar>>()
const safeArea = ref(150)
const handleTabSwitch = (distance: number) => {
scrollbar.value?.scrollTo({
left: distance,
behavior: 'smooth',
})
}
const scrollToCurrentTab = () => {
nextTick(() => {
const currentTabElement = document.querySelector(`[data-tab-path="${currentTabPath.value}"]`) as HTMLElement
const tabBarScrollWrapper = document.querySelector('.tab-bar-scroller-wrapper .n-scrollbar-container')
const tabBarScrollContent = document.querySelector('.tab-bar-scroller-content')
if (currentTabElement && tabBarScrollContent && tabBarScrollWrapper) {
const tabLeft = currentTabElement.offsetLeft
const tabBarLeft = tabBarScrollWrapper.scrollLeft
const wrapperWidth = tabBarScrollWrapper.getBoundingClientRect().width
const tabWidth = currentTabElement.getBoundingClientRect().width
const containerPR = Number.parseFloat(window.getComputedStyle(tabBarScrollContent).paddingRight)
if (tabLeft + tabWidth + safeArea.value + containerPR > wrapperWidth + tabBarLeft) {
handleTabSwitch(tabLeft + tabWidth + containerPR - wrapperWidth + safeArea.value)
}
else if (tabLeft - safeArea.value < tabBarLeft) {
handleTabSwitch(tabLeft - safeArea.value)
}
}
})
}
const handleScroll = throttle({ interval: 120 }, (step: number) => {
scrollbar.value?.scrollBy({
left: step * 400,
behavior: 'smooth',
})
})
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
handleScroll(e.deltaY > 0 ? 1 : -1)
}
}
watchEffect(() => {
if (currentTabPath.value) {
scrollToCurrentTab()
}
})
return {
scrollbar,
onWheel,
safeArea,
handleTabSwitch,
}
}

35
src/hooks/useTableDrag.ts Normal file
View File

@ -0,0 +1,35 @@
import type { NDataTable } from 'naive-ui'
import { useDraggable } from 'vue-draggable-plus'
export function useTableDrag<T = unknown>(params: {
tableRef: Ref<InstanceType<typeof NDataTable> | undefined>
data: Ref<T[]>
onRowDrag: (rows: T[]) => void
}) {
const tableEl = computed(() => params.tableRef?.value?.$el as HTMLElement)
const tableBodyRef = ref<HTMLElement | undefined>(undefined)
const { start } = useDraggable(tableBodyRef, params.data, {
immediate: false,
animation: 150,
handle: '.drag-handle',
onEnd: (event) => {
const { oldIndex, newIndex } = event
const start = Math.min(oldIndex!, newIndex!)
const end = Math.max(oldIndex!, newIndex!) - start + 1
const changedRows = [...params.data.value].splice(start, end)
params.onRowDrag(unref([...changedRows]))
},
})
onMounted(async () => {
while (!tableBodyRef.value) {
tableBodyRef.value = tableEl.value?.querySelector('tbody') || undefined
await new Promise(resolve => setTimeout(resolve, 100))
}
})
watchOnce(() => tableBodyRef.value, (el) => {
el && start()
})
}

26
src/layouts/Content.vue Normal file
View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { useAppStore, useRouteStore } from '@/store'
const appStore = useAppStore()
const routeStore = useRouteStore()
</script>
<template>
<n-el
class="h-full"
:class="[
appStore.layoutMode === 'full-content' ? 'p-0' : 'p-16px',
]"
style="background-color: var(--action-color);"
>
<router-view
v-slot="{ Component, route }"
>
<transition :name="appStore.transitionAnimation" mode="out-in">
<keep-alive :include="routeStore.cacheRoutes">
<component :is="Component" v-if="appStore.loadFlag" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</n-el>
</template>

View File

@ -1,47 +1,120 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LayoutMode } from '@/store/app' import type { ProLayoutMode } from 'pro-naive-ui'
const value = defineModel<LayoutMode>('value', { required: true }) const value = defineModel<ProLayoutMode>('value', { required: true })
</script> </script>
<template> <template>
<div class="flex-center gap-4"> <div class="selector-wapper gap-4">
<n-tooltip placement="bottom" trigger="hover"> <n-tooltip placement="top" trigger="hover">
<template #trigger> <template #trigger>
<n-el <n-el
:class="{ :class="{
'outline outline-2': value === 'leftMenu', 'outline outline-2': value === 'vertical',
}" }"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)" class="grid grid-cols-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'leftMenu'" @click="value = 'vertical'"
>
<div class="bg-[var(--primary-color)] row-span-2" />
<div class="bg-[var(--primary-color-suppl)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.leftMenu') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'topMenu',
}"
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2)"
@click="value = 'topMenu'"
> >
<div class="bg-[var(--primary-color)]" /> <div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" /> <div class="bg-[var(--divider-color)]" />
</n-el> </n-el>
</template> </template>
<span> {{ $t('app.topMenu') }} </span> <span> {{ $t('app.verticalLayout') }} </span>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'horizontal',
}"
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'horizontal'"
>
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.horizontalLayout') }} </span>
</n-tooltip>
<n-tooltip placement="top" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'two-column',
}"
class="grid grid-cols-[10%_15%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'two-column'"
>
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--primary-color-suppl)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.twoColumnLayout') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'mixed-two-column',
}"
class="grid grid-cols-[10%_15%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'mixed-two-column'"
>
<div class="bg-[var(--primary-color-suppl)] row-span-2" />
<div class="bg-[var(--primary-color-suppl)] row-span-2" />
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.mixedTwoColumnLayout') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'sidebar',
}"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'sidebar'"
>
<div class="bg-[var(--divider-color)] col-span-2" />
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.sidebarLayout') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'mixed-sidebar',
}"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'mixed-sidebar'"
>
<div class="bg-[var(--primary-color)] col-span-2" />
<div class="bg-[var(--primary-color-suppl)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.mixedSidebarLayout') }} </span>
</n-tooltip> </n-tooltip>
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss" scoped>
.selector-wapper{
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.grid{ .grid{
height: 60px; height: 60px;
width: 86px; width: 86px;

View File

@ -5,6 +5,16 @@ const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const name = import.meta.env.VITE_APP_NAME const name = import.meta.env.VITE_APP_NAME
const hidenLogoText = computed(() => {
if (['sidebar', 'mixed-sidebar', 'horizontal'].includes(appStore.layoutMode)) {
return false
}
if (['two-column', 'mixed-two-column'].includes(appStore.layoutMode)) {
return true
}
return appStore.collapsed
})
</script> </script>
<template> <template>
@ -14,7 +24,7 @@ const name = import.meta.env.VITE_APP_NAME
> >
<svg-icons-logo class="text-1.5em" /> <svg-icons-logo class="text-1.5em" />
<span <span
v-show="!appStore.collapsed" v-show="!hidenLogoText"
class="text-ellipsis overflow-hidden whitespace-nowrap" class="text-ellipsis overflow-hidden whitespace-nowrap"
>{{ name }}</span> >{{ name }}</span>
</div> </div>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import Search from '../header/Search.vue'
import Notices from '../header/Notices.vue'
import UserCenter from '../header/UserCenter.vue'
import Setting from './Setting.vue'
const showDrawer = defineModel<boolean>('show', { default: false })
</script>
<template>
<n-drawer
v-model:show="showDrawer"
:width="280"
placement="right"
:mask-closable="true"
:close-on-esc="true"
>
<n-drawer-content :native-scrollbar="false" :body-content-style="{ padding: '0' }">
<template #header>
<div class="flex items-center">
<UserCenter />
<div class="ml-auto" />
<Search />
<Notices />
</div>
</template>
<slot />
<template #footer>
<DarkModeSwitch />
<LangsSwitch />
<div class="ml-auto" />
<Setting />
</template>
</n-drawer-content>
</n-drawer>
</template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
list?: Message.List[] list?: Entity.Message[]
} }
const props = defineProps<Props>() const { list } = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
interface Emits { interface Emits {
@ -13,7 +13,7 @@ interface Emits {
<template> <template>
<n-scrollbar style="height: 400px"> <n-scrollbar style="height: 400px">
<n-list hoverable clickable> <n-list hoverable clickable>
<n-list-item v-for="(item) in props.list" :key="item.id" @click="emit('read', item.id)"> <n-list-item v-for="(item) in list" :key="item.id" @click="emit('read', item.id)">
<n-thing content-indented :class="{ 'opacity-30': item.isRead }"> <n-thing content-indented :class="{ 'opacity-30': item.isRead }">
<template #header> <template #header>
<n-ellipsis :line-clamp="1"> <n-ellipsis :line-clamp="1">

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
</script>
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.showSetting = !appStore.showSetting">
<div>
<icon-park-outline-setting-two />
</div>
</CommonWrapper>
</template>
<span>{{ $t('app.setting') }}</span>
</n-tooltip>
</template>

View File

@ -0,0 +1,143 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
import LayoutSelector from './LayoutSelector.vue'
const appStore = useAppStore()
const { t } = useI18n()
const transitionSelectorOptions = computed(() => {
return [
{
label: t('app.transitionNull'),
value: '',
},
{
label: t('app.transitionFadeSlide'),
value: 'fade-slide',
},
{
label: t('app.transitionFadeBottom'),
value: 'fade-bottom',
},
{
label: t('app.transitionFadeScale'),
value: 'fade-scale',
},
{
label: t('app.transitionZoomFade'),
value: 'zoom-fade',
},
{
label: t('app.transitionZoomOut'),
value: 'zoom-out',
},
{
label: t('app.transitionSoft'),
value: 'fade',
},
]
})
const palette = [
'#ffb8b8',
'#d03050',
'#F0A020',
'#fff200',
'#ffda79',
'#18A058',
'#006266',
'#22a6b3',
'#18dcff',
'#2080F0',
'#c56cf0',
'#be2edd',
'#706fd3',
'#4834d4',
'#130f40',
'#4b4b4b',
]
function resetSetting() {
window.$dialog.warning({
title: t('app.resetSettingTitle'),
content: t('app.resetSettingContent'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
appStore.resetAlltheme()
window.$message.success(t('app.resetSettingMeaasge'))
},
})
}
</script>
<template>
<n-drawer v-model:show="appStore.showSetting" :width="360">
<n-drawer-content :title="t('app.systemSetting')" closable>
<n-space vertical>
<n-divider>{{ $t('app.layoutSetting') }}</n-divider>
<LayoutSelector v-model:value="appStore.layoutMode" />
<n-divider>{{ $t('app.themeSetting') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.colorWeak') }}
<n-switch :value="appStore.colorWeak" @update:value="appStore.toggleColorWeak" />
</n-space>
<n-space justify="space-between">
{{ $t('app.blackAndWhite') }}
<n-switch :value="appStore.grayMode" @update:value="appStore.toggleGrayMode" />
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.themeColor') }}
<n-color-picker
v-model:value="appStore.primaryColor" class="w-10em" :swatches="palette"
@update:value="appStore.setPrimaryColor"
/>
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.pageTransition') }}
<n-select
v-model:value="appStore.transitionAnimation" class="w-10em"
:options="transitionSelectorOptions" @update:value="appStore.reloadPage"
/>
</n-space>
<n-divider>{{ $t('app.interfaceDisplay') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.logoDisplay') }}
<n-switch v-model:value="appStore.showLogo" />
</n-space>
<n-space justify="space-between">
{{ $t('app.topProgress') }}
<n-switch v-model:value="appStore.showProgress" />
</n-space>
<n-space justify="space-between">
{{ $t('app.multitab') }}
<n-switch v-model:value="appStore.showTabs" />
</n-space>
<n-space justify="space-between">
{{ $t('app.bottomCopyright') }}
<n-switch v-model:value="appStore.showFooter" />
</n-space>
<n-space justify="space-between">
{{ $t('app.breadcrumb') }}
<n-switch v-model:value="appStore.showBreadcrumb" />
</n-space>
<n-space justify="space-between">
{{ $t('app.BreadcrumbIcon') }}
<n-switch v-model:value="appStore.showBreadcrumbIcon" />
</n-space>
<n-space justify="space-between">
{{ $t('app.watermake') }}
<n-switch v-model:value="appStore.showWatermark" />
</n-space>
</n-space>
<template #footer>
<n-button type="error" @click="resetSetting">
{{ $t('app.reset') }}
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</template>

View File

@ -2,18 +2,26 @@
import { useAppStore } from '@/store' import { useAppStore } from '@/store'
const appStore = useAppStore() const appStore = useAppStore()
useMagicKeys({
passive: false,
onEventFired(e) {
if (e.key === 'F11' && e.type === 'keydown') {
e.preventDefault()
appStore.toggleFullScreen()
}
},
})
</script> </script>
<template> <template>
<n-tooltip placement="bottom" trigger="hover"> <n-tooltip placement="bottom" trigger="hover">
<template #trigger> <template #trigger>
<CommonWrapper @click="appStore.toggleFullScreen()"> <CommonWrapper @click="appStore.toggleFullScreen">
<icon-park-outline-off-screen-two v-if="appStore.fullScreen" /> <icon-park-outline-off-screen v-if="appStore.fullScreen" />
<icon-park-outline-full-screen-two v-else /> <icon-park-outline-full-screen v-else />
</CommonWrapper> </CommonWrapper>
</template> </template>
<span>{{ $t('app.toggleFullScreen') }}</span> <span>{{ $t('app.toggleFullScreen') }}</span>
</n-tooltip> </n-tooltip>
</template> </template>
<style scoped></style>

View File

@ -2,7 +2,7 @@
import { group } from 'radash' import { group } from 'radash'
import NoticeList from '../common/NoticeList.vue' import NoticeList from '../common/NoticeList.vue'
const MassageData = ref<Message.List[]>([ const MassageData = ref<Entity.Message[]>([
{ {
id: 0, id: 0,
type: 0, type: 0,

View File

@ -1,59 +1,219 @@
<script setup lang="ts"> <script setup lang="ts">
import { NFlex, NTag, NText } from 'naive-ui' import { useBoolean } from '@/hooks'
import { useRouteStore } from '@/store' import { useAppStore, useRouteStore } from '@/store'
import { renderIcon } from '@/utils'
const appStore = useAppStore()
const routeStore = useRouteStore() const routeStore = useRouteStore()
//
const searchValue = ref('') const searchValue = ref('')
//
const selectedIndex = ref<number>(0)
const { bool: showModal, setTrue: openModal, setFalse: closeModal, toggle: toggleModal } = useBoolean(false)
//
const { bool: keyboardFlag, setTrue: setKeyboardTrue, setFalse: setKeyboardFalse } = useBoolean(false)
const { ctrl_k, arrowup, arrowdown, enter/* keys you want to monitor */ } = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown')
e.preventDefault()
},
})
//
watchEffect(() => {
if (ctrl_k.value)
toggleModal()
})
const { t } = useI18n() const { t } = useI18n()
//
const options = computed(() => { const options = computed(() => {
if (!searchValue.value)
return []
return routeStore.rowRoutes.filter((item) => { return routeStore.rowRoutes.filter((item) => {
const conditions = [ const conditions = [
t(`route.${String(item.name)}`, item['meta.title'] || item.name)?.includes(searchValue.value), t(`route.${String(item.name)}`, item.title || item.name)?.includes(searchValue.value),
item.path?.includes(searchValue.value), item.path?.includes(searchValue.value),
] ]
return conditions.some(condition => condition) return conditions.some(condition => !item.hide && condition)
}).map((item) => { }).map((item) => {
return { return {
label: t(`route.${String(item.name)}`, item['meta.title'] || item.name), label: t(`route.${String(item.name)}`, item.title || item.name),
value: item.path, value: item.path,
icon: item['meta.icon'], icon: item.icon,
} }
}) })
}) })
function renderLabel(option: any) {
return h(NFlex, {}, {
default: () => [
h(NTag, { size: 'small', type: 'primary', bordered: false }, { icon: renderIcon(option.icon), default: () => option.label }),
h(NText, { depth: 3 }, { default: () => option.value }),
],
})
}
const router = useRouter() const router = useRouter()
//
function handleClose() {
searchValue.value = ''
selectedIndex.value = 0
closeModal()
}
//
function handleInputChange() {
selectedIndex.value = 0
}
//
function handleSelect(value: string) { function handleSelect(value: string) {
handleClose()
router.push(value) router.push(value)
nextTick(() => { nextTick(() => {
searchValue.value = '' searchValue.value = ''
}) })
} }
watchEffect(() => {
//
if (!showModal.value || !options.value.length)
return
// mouseover
setKeyboardTrue()
if (arrowup.value)
handleArrowup()
if (arrowdown.value)
handleArrowdown()
if (enter.value)
handleEnter()
})
const scrollbarRef = ref()
//
function handleArrowup() {
if (selectedIndex.value === 0)
selectedIndex.value = options.value.length - 1
else
selectedIndex.value--
handleScroll(selectedIndex.value)
}
//
function handleArrowdown() {
if (selectedIndex.value === options.value.length - 1)
selectedIndex.value = 0
else
selectedIndex.value++
handleScroll(selectedIndex.value)
}
function handleScroll(currentIndex: number) {
// 6,6
const keepIndex = 5
// gappadding
const elHeight = 70
const distance = currentIndex * elHeight > keepIndex * elHeight ? currentIndex * elHeight - keepIndex * elHeight : 0
scrollbarRef.value?.scrollTo({
top: distance,
})
}
//
function handleEnter() {
const target = options.value[selectedIndex.value]
if (target)
handleSelect(target.value)
}
//
function handleMouseEnter(index: number) {
if (keyboardFlag.value)
return
selectedIndex.value = index
}
</script> </script>
<template> <template>
<n-auto-complete <CommonWrapper @click="openModal">
v-model:value="searchValue" class="w-20em m-r-1em" :input-props="{ <icon-park-outline-search />
autocomplete: 'disabled', <n-tag v-if="!appStore.isMobile" round size="small" class="font-mono cursor-pointer">
}" :options="options" :render-label="renderLabel" :placeholder="$t('app.searchPlaceholder')" clearable @select="handleSelect" CtrlK
</n-tag>
</CommonWrapper>
<n-modal
v-model:show="showModal"
class="w-560px fixed top-60px inset-x-0 max-w-full"
size="small"
preset="card"
:segmented="{
content: true,
footer: true,
}"
:closable="false"
@after-leave="handleClose"
> >
<template #header>
<n-input v-model:value="searchValue" :placeholder="$t('app.searchPlaceholder')" clearable size="large" @input="handleInputChange">
<template #prefix> <template #prefix>
<n-icon> <n-icon>
<icon-park-outline-search /> <icon-park-outline-search />
</n-icon> </n-icon>
</template> </template>
</n-auto-complete> </n-input>
</template> </template>
<n-scrollbar ref="scrollbarRef" class="h-450px">
<ul
v-if="options.length"
class="flex flex-col gap-8px p-8px p-r-3"
>
<n-el
v-for="(option, index) in options"
:key="option.value" tag="li" role="option"
class="cursor-pointer shadow h-62px"
:class="{ 'text-[var(--base-color)] bg-[var(--primary-color-hover)]': index === selectedIndex }"
@click="handleSelect(option.value)"
@mouseenter="handleMouseEnter(index)"
@mousemove="setKeyboardFalse"
>
<div class="grid grid-rows-2 grid-cols-[40px_1fr_30px] h-full p-2">
<div class="row-span-2 place-self-center">
<nova-icon :icon="option.icon" />
</div>
<span>{{ option.label }}</span>
<icon-park-outline-right class="row-span-2 place-self-center" />
<span class="op-70">{{ option.value }}</span>
</div>
</n-el>
</ul>
<style scoped></style> <n-empty v-else size="large" class="h-450px flex-center" />
</n-scrollbar>
<template #footer>
<n-flex>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3" /></g></svg>
<span>{{ $t('common.choose') }}</span>
</div>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3" /></g></svg>
<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3" /></g></svg>
<span>{{ $t('common.navigate') }}</span>
</div>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956" /></g></svg>
<span>{{ $t('common.close') }}</span>
</div>
</n-flex>
</template>
</n-modal>
</template>

View File

@ -1,158 +0,0 @@
<script setup lang="ts">
import LayoutSelector from '../common/LayoutSelector.vue'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const { t } = useI18n()
const drawerActive = ref(false)
function openSetting() {
drawerActive.value = !drawerActive.value
}
const transitionSelectorOptions = computed(() => {
return [
{
label: t('app.transitionNull'),
value: '',
},
{
label: t('app.transitionFadeSlide'),
value: 'fade-slide',
},
{
label: t('app.transitionFadeBottom'),
value: 'fade-bottom',
},
{
label: t('app.transitionFadeScale'),
value: 'fade-scale',
},
{
label: t('app.transitionZoomFade'),
value: 'zoom-fade',
},
{
label: t('app.transitionZoomOut'),
value: 'zoom-out',
},
{
label: t('app.transitionSoft'),
value: 'fade',
},
]
})
const palette = [
'#ffb8b8',
'#d03050',
'#F0A020',
'#fff200',
'#ffda79',
'#18A058',
'#006266',
'#22a6b3',
'#18dcff',
'#2080F0',
'#c56cf0',
'#be2edd',
'#706fd3',
'#4834d4',
'#130f40',
'#4b4b4b',
]
function resetSetting() {
window.$dialog.warning({
title: t('app.resetSettingTitle'),
content: t('app.resetSettingContent'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
appStore.resetAlltheme()
window.$message.success(t('app.resetSettingMeaasge'))
},
})
}
</script>
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="openSetting">
<div>
<icon-park-outline-setting-two />
<n-drawer v-model:show="drawerActive" :width="360">
<n-drawer-content :title="t('app.systemSetting')" closable>
<n-space vertical>
<n-divider>{{ $t('app.layoutSetting') }}</n-divider>
<LayoutSelector v-model:value="appStore.layoutMode" />
<n-divider>{{ $t('app.themeSetting') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.colorWeak') }}
<n-switch :value="appStore.colorWeak" @update:value="appStore.toggleColorWeak" />
</n-space>
<n-space justify="space-between">
{{ $t('app.blackAndWhite') }}
<n-switch :value="appStore.grayMode" @update:value="appStore.toggleGrayMode" />
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.themeColor') }}
<n-color-picker
v-model:value="appStore.primaryColor" class="w-10em" :swatches="palette"
@update:value="appStore.setPrimaryColor"
/>
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.pageTransition') }}
<n-select
v-model:value="appStore.transitionAnimation" class="w-10em"
:options="transitionSelectorOptions" @update:value="appStore.reloadPage"
/>
</n-space>
<n-divider>{{ $t('app.interfaceDisplay') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.logoDisplay') }}
<n-switch v-model:value="appStore.showLogo" />
</n-space>
<n-space justify="space-between">
{{ $t('app.topProgress') }}
<n-switch v-model:value="appStore.showProgress" />
</n-space>
<n-space justify="space-between">
{{ $t('app.multitab') }}
<n-switch v-model:value="appStore.showTabs" />
</n-space>
<n-space justify="space-between">
{{ $t('app.bottomCopyright') }}
<n-switch v-model:value="appStore.showFooter" />
</n-space>
<n-space justify="space-between">
{{ $t('app.breadcrumb') }}
<n-switch v-model:value="appStore.showBreadcrumb" />
</n-space>
<n-space justify="space-between">
{{ $t('app.BreadcrumbIcon') }}
<n-switch v-model:value="appStore.showBreadcrumbIcon" />
</n-space>
<n-space justify="space-between">
{{ $t('app.watermake') }}
<n-switch v-model:value="appStore.showWatermark" />
</n-space>
</n-space>
<template #footer>
<n-button type="error" @click="resetSetting">
{{ $t('app.reset') }}
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</div>
</CommonWrapper>
</template>
<span>{{ $t('app.setting') }}</span>
</n-tooltip>
</template>

View File

@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { renderIcon } from '@/utils/icon'
import { useAuthStore } from '@/store' import { useAuthStore } from '@/store'
import IconGithub from '~icons/icon-park-outline/github' import { renderIcon } from '@/utils/icon'
import IconUser from '~icons/icon-park-outline/user'
import IconLogout from '~icons/icon-park-outline/logout'
import IconBookOpen from '~icons/icon-park-outline/book-open' import IconBookOpen from '~icons/icon-park-outline/book-open'
import IconGithub from '~icons/icon-park-outline/github'
import IconLogout from '~icons/icon-park-outline/logout'
import IconUser from '~icons/icon-park-outline/user'
const { t } = useI18n() const { t } = useI18n()
const { userInfo, resetAuthStore } = useAuthStore() const { userInfo, logout } = useAuthStore()
const router = useRouter() const router = useRouter()
const options = computed(() => { const options = computed(() => {
@ -56,12 +56,12 @@ function handleSelect(key: string | number) {
positiveText: t('common.confirm'), positiveText: t('common.confirm'),
negativeText: t('common.cancel'), negativeText: t('common.cancel'),
onPositiveClick: () => { onPositiveClick: () => {
resetAuthStore() logout()
}, },
}) })
} }
if (key === 'userCenter') if (key === 'userCenter')
router.push('/userCenter') router.push('/user-center')
if (key === 'guthub') if (key === 'guthub')
window.open('https://github.com/chansee97/nova-admin') window.open('https://github.com/chansee97/nova-admin')
@ -70,7 +70,7 @@ function handleSelect(key: string | number) {
window.open('https://gitee.com/chansee97/nova-admin') window.open('https://gitee.com/chansee97/nova-admin')
if (key === 'docs') if (key === 'docs')
window.open('https://nova-admin-docs.netlify.app/') window.open('https://nova-admin-docs.pages.dev/')
} }
</script> </script>
@ -82,7 +82,7 @@ function handleSelect(key: string | number) {
> >
<n-avatar <n-avatar
round round
class="cursor-pointer"
:src="userInfo?.avatar" :src="userInfo?.avatar"
> >
<template #fallback> <template #fallback>

View File

@ -1,33 +1,29 @@
/* 侧边栏组件 */ import BackTop from './common/BackTop.vue'
import Logo from './sider/Logo.vue' import Setting from './common/Setting.vue'
import Menu from './sider/Menu.vue' import SettingDrawer from './common/SettingDrawer.vue'
import Logo from './common/Logo.vue'
import MobileDrawer from './common/MobileDrawer.vue'
/* 头部栏组件 */
import Breadcrumb from './header/Breadcrumb.vue' import Breadcrumb from './header/Breadcrumb.vue'
import CollapaseButton from './header/CollapaseButton.vue' import CollapaseButton from './header/CollapaseButton.vue'
import FullScreen from './header/FullScreen.vue' import FullScreen from './header/FullScreen.vue'
import Setting from './header/Setting.vue'
import Notices from './header/Notices.vue' import Notices from './header/Notices.vue'
import UserCenter from './header/UserCenter.vue'
import Search from './header/Search.vue' import Search from './header/Search.vue'
import UserCenter from './header/UserCenter.vue'
/* 标签栏组件 */
import TabBar from './tab/TabBar.vue' import TabBar from './tab/TabBar.vue'
/* 其他组件 */
// 返回顶部
import BackTop from './common/BackTop.vue'
export { export {
BackTop,
Breadcrumb, Breadcrumb,
CollapaseButton, CollapaseButton,
Menu,
Logo,
FullScreen, FullScreen,
Setting, Logo,
MobileDrawer,
Notices, Notices,
UserCenter,
Search, Search,
Setting,
SettingDrawer,
TabBar, TabBar,
BackTop, UserCenter,
} }

View File

@ -1,19 +0,0 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
import { useRouteStore } from '@/store/route'
const appStore = useAppStore()
const routesStore = useRouteStore()
</script>
<template>
<n-menu
:collapsed="appStore.collapsed"
:indent="20"
:collapsed-width="64"
:options="routesStore.menus"
:value="routesStore.activeMenu"
/>
</template>
<style scoped></style>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
let previousLayoutMode = appStore.layoutMode
function enterFullContent() {
previousLayoutMode = appStore.layoutMode
appStore.layoutMode = 'full-content'
}
function exitFullContent() {
// vertical
if (previousLayoutMode === 'full-content' || !previousLayoutMode) {
previousLayoutMode = 'vertical'
}
appStore.layoutMode = previousLayoutMode
}
</script>
<template>
<n-tooltip v-if="!appStore.isMobile" placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="enterFullContent">
<icon-park-outline-full-screen-one />
</CommonWrapper>
</template>
{{ $t('app.togglContentFullScreen') }}
</n-tooltip>
<Teleport to="body">
<div
v-if="appStore.layoutMode === 'full-content'"
class="fixed top-4 right-0 z-[9999]"
>
<n-tooltip placement="left" trigger="hover">
<template #trigger>
<n-el
class="bg-[var(--primary-color)] c-[var(--base-color)] rounded-l-lg shadow-lg p-2 cursor-pointer"
@click="exitFullContent"
>
<icon-park-outline-off-screen-one />
</n-el>
</template>
{{ $t('app.togglContentFullScreen') }}
</n-tooltip>
</div>
</Teleport>
</template>

View File

@ -9,6 +9,7 @@ const { t } = useI18n()
function renderDropTabsLabel(option: any) { function renderDropTabsLabel(option: any) {
return t(`route.${String(option.name)}`, option.meta.title) return t(`route.${String(option.name)}`, option.meta.title)
} }
function renderDropTabsIcon(option: any) { function renderDropTabsIcon(option: any) {
return renderIcon(option.meta.icon)!() return renderIcon(option.meta.icon)!()
} }
@ -26,6 +27,7 @@ function handleDropTabs(key: string, option: any) {
:render-icon="renderDropTabsIcon" :render-icon="renderDropTabsIcon"
trigger="click" trigger="click"
size="small" size="small"
key-field="fullPath"
@select="handleDropTabs" @select="handleDropTabs"
> >
<CommonWrapper> <CommonWrapper>

View File

@ -1,24 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RouteLocationNormalized } from 'vue-router' import type { RouteLocationNormalized } from 'vue-router'
import Reload from './Reload.vue'
import DropTabs from './DropTabs.vue'
import { useAppStore, useTabStore } from '@/store' import { useAppStore, useTabStore } from '@/store'
import IconRedo from '~icons/icon-park-outline/redo' import { useTabScroll } from '@/hooks/useTabScroll'
import { useDraggable } from 'vue-draggable-plus'
import IconClose from '~icons/icon-park-outline/close' import IconClose from '~icons/icon-park-outline/close'
import IconDelete from '~icons/icon-park-outline/delete-four' import IconDelete from '~icons/icon-park-outline/delete-four'
import IconFullwith from '~icons/icon-park-outline/fullwidth'
import IconRedo from '~icons/icon-park-outline/redo'
import IconLeft from '~icons/icon-park-outline/to-left' import IconLeft from '~icons/icon-park-outline/to-left'
import IconRight from '~icons/icon-park-outline/to-right' import IconRight from '~icons/icon-park-outline/to-right'
import IconFullwith from '~icons/icon-park-outline/fullwidth' import ContentFullScreen from './ContentFullScreen.vue'
import DropTabs from './DropTabs.vue'
import Reload from './Reload.vue'
import TabBarItem from './TabBarItem.vue'
const tabStore = useTabStore() const tabStore = useTabStore()
const { tabs } = storeToRefs(useTabStore())
const appStore = useAppStore() const appStore = useAppStore()
const { scrollbar, onWheel } = useTabScroll(computed(() => tabStore.currentTabPath))
const router = useRouter() const router = useRouter()
function handleTab(route: RouteLocationNormalized) { function handleTab(route: RouteLocationNormalized) {
router.push(route.path) router.push(route.fullPath)
}
function handleClose(path: string) {
tabStore.closeTab(path)
} }
const { t } = useI18n() const { t } = useI18n()
const options = computed(() => { const options = computed(() => {
@ -70,16 +74,16 @@ function handleSelect(key: string) {
appStore.reloadPage() appStore.reloadPage()
}, },
closeCurrent() { closeCurrent() {
tabStore.closeTab(currentRoute.value.path) tabStore.closeTab(currentRoute.value.fullPath)
}, },
closeOther() { closeOther() {
tabStore.closeOtherTabs(currentRoute.value.path) tabStore.closeOtherTabs(currentRoute.value.fullPath)
}, },
closeLeft() { closeLeft() {
tabStore.closeLeftTabs(currentRoute.value.path) tabStore.closeLeftTabs(currentRoute.value.fullPath)
}, },
closeRight() { closeRight() {
tabStore.closeRightTabs(currentRoute.value.path) tabStore.closeRightTabs(currentRoute.value.fullPath)
}, },
closeAll() { closeAll() {
tabStore.closeAllTabs() tabStore.closeAllTabs()
@ -100,55 +104,53 @@ function handleContextMenu(e: MouseEvent, route: RouteLocationNormalized) {
function onClickoutside() { function onClickoutside() {
showDropdown.value = false showDropdown.value = false
} }
const el = ref()
useDraggable(el, tabs, {
animation: 150,
ghostClass: 'ghost',
})
</script> </script>
<template> <template>
<div class="wh-full flex items-end"> <n-scrollbar ref="scrollbar" class="relative flex h-full tab-bar-scroller-wrapper" content-class="h-full pr-34 tab-bar-scroller-content" :x-scrollable="true" @wheel="onWheel">
<n-tabs <div class="p-l-2 flex wh-full relative">
type="card" <div class="flex items-end">
size="small" <TabBarItem
:tabs-padding="15" v-for="item in tabStore.pinTabs" :key="item.fullPath" :value="tabStore.currentTabPath" :route="item"
:value="tabStore.currentTabPath"
@close="handleClose"
>
<n-tab
v-for="item in tabStore.pinTabs"
:key="item.path"
:name="item.path"
@click="router.push(item.path)"
>
<div class="flex-x-center gap-2">
<nova-icon :icon="item.meta.icon" /> {{ $t(`route.${String(item.name)}`, item.meta.title) }}
</div>
</n-tab>
<n-tab
v-for="item in tabStore.tabs"
:key="item.path"
closable
:name="item.path as string"
@click="handleTab(item)" @click="handleTab(item)"
@contextmenu="handleContextMenu($event, item)"
>
<div class="flex-x-center gap-2">
<nova-icon :icon="item.meta.icon" /> {{ $t(`route.${String(item.name)}`, item.meta.title) }}
</div>
</n-tab>
<template #suffix>
<Reload />
<DropTabs />
</template>
</n-tabs>
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="x"
:y="y"
:options="options"
:show="showDropdown"
:on-clickoutside="onClickoutside"
@select="handleSelect"
/> />
</div> </div>
<div ref="el" class="flex items-end flex-1">
<TabBarItem
v-for="item in tabStore.tabs"
:key="item.fullPath"
:value="tabStore.currentTabPath"
:route="item"
closable
:data-tab-path="item.fullPath"
@close="tabStore.closeTab"
@click="handleTab(item)"
@contextmenu="handleContextMenu($event, item)"
/>
<n-dropdown
placement="bottom-start" trigger="manual" :x="x" :y="y" :options="options" :show="showDropdown"
:on-clickoutside="onClickoutside" @select="handleSelect"
/>
</div>
</div>
<n-el class="absolute right-0 top-0 flex items-center gap-1 bg-[var(--card-color)] h-full">
<Reload />
<ContentFullScreen />
<DropTabs />
</n-el>
</n-scrollbar>
</template> </template>
<style scoped></style>./DropTabs.vue <style scoped>
.ghost {
opacity: 0.5;
background: #c4f6d5;
}
</style>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { RouteLocationNormalized } from 'vue-router'
const { route, value, closable = false } = defineProps<{
route: RouteLocationNormalized
value: string
closable?: boolean
}>()
const emit = defineEmits<{
close: [string]
}>()
</script>
<template>
<n-el
class="cursor-pointer p-x-4 p-y-2 m-x-2px b b-[--divider-color] b-b-[#0000] rounded-[--border-radius]"
:class="[
value === route.fullPath ? 'c-[--primary-color]' : 'c-[--text-color-2]',
value === route.fullPath ? 'bg-[#0000]' : 'bg-[--tab-color]',
closable && 'p-r-2',
]"
style="transition: box-shadow .3s var(--n-bezier), color .3s var(--n-bezier), background-color .3s var(--n-bezier), border-color .3s var(--n-bezier);"
>
<div class="flex-center gap-2 text-nowrap">
<nova-icon :icon="route.meta.icon" />
<span>{{ $t(`route.${String(route.name)}`, route.meta.title) }}</span>
<button
v-if="closable"
type="button"
class="bg-transparent h-18px w-18px flex-center text-[var(--close-icon-color)] hover:bg-[var(--close-color-hover)] rounded-3px"
style="transition: background-color .3s var(--n-bezier), color .3s var(--n-bezier);"
@click.stop="emit('close', route.fullPath)"
>
<n-icon size="14">
<svg viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g fill="currentColor" fill-rule="nonzero"><path d="M2.08859116,2.2156945 L2.14644661,2.14644661 C2.32001296,1.97288026 2.58943736,1.95359511 2.7843055,2.08859116 L2.85355339,2.14644661 L6,5.293 L9.14644661,2.14644661 C9.34170876,1.95118446 9.65829124,1.95118446 9.85355339,2.14644661 C10.0488155,2.34170876 10.0488155,2.65829124 9.85355339,2.85355339 L6.707,6 L9.85355339,9.14644661 C10.0271197,9.32001296 10.0464049,9.58943736 9.91140884,9.7843055 L9.85355339,9.85355339 C9.67998704,10.0271197 9.41056264,10.0464049 9.2156945,9.91140884 L9.14644661,9.85355339 L6,6.707 L2.85355339,9.85355339 C2.65829124,10.0488155 2.34170876,10.0488155 2.14644661,9.85355339 C1.95118446,9.65829124 1.95118446,9.34170876 2.14644661,9.14644661 L5.293,6 L2.14644661,2.85355339 C1.97288026,2.67998704 1.95359511,2.41056264 2.08859116,2.2156945 L2.14644661,2.14644661 L2.08859116,2.2156945 Z" /></g></g></svg>
</n-icon>
</button>
</div>
</n-el>
</template>

View File

@ -1,15 +1,148 @@
<script setup lang="ts"> <script setup lang="ts">
import leftMenu from './leftMenu.layout.vue' import { useAppStore, useRouteStore } from '@/store'
import topMenu from './topMenu.layout.vue' import {
import { useAppStore } from '@/store/app' BackTop,
Breadcrumb,
CollapaseButton,
FullScreen,
Logo,
MobileDrawer,
Notices,
Search,
Setting,
SettingDrawer,
TabBar,
UserCenter,
} from './components'
import Content from './Content.vue'
import { ProLayout, useLayoutMenu } from 'pro-naive-ui'
const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
const layoutMap = { const routeStore = useRouteStore()
leftMenu,
topMenu, const { layoutMode } = storeToRefs(useAppStore())
}
const {
layout,
activeKey,
} = useLayoutMenu({
mode: layoutMode,
accordion: true,
menus: routeStore.menus,
})
watch(() => route.path, () => {
activeKey.value = routeStore.activeMenu
}, { immediate: true })
//
const showMobileDrawer = ref(false)
const sidebarWidth = ref(240)
const sidebarCollapsedWidth = ref(64)
const hasHorizontalMenu = computed(() => ['horizontal', 'mixed-two-column', 'mixed-sidebar'].includes(layoutMode.value))
const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.value) || appStore.isMobile)
</script> </script>
<template> <template>
<component :is="layoutMap[appStore.layoutMode]" /> <SettingDrawer />
<ProLayout
v-model:collapsed="appStore.collapsed"
:mode="layoutMode"
:is-mobile="appStore.isMobile"
:show-logo="appStore.showLogo && !appStore.isMobile"
:show-footer="appStore.showFooter"
:show-tabbar="appStore.showTabs"
nav-fixed
show-nav
show-sidebar
:nav-height="60"
:tabbar-height="45"
:footer-height="40"
:sidebar-width="sidebarWidth"
:sidebar-collapsed-width="sidebarCollapsedWidth"
>
<template #logo>
<Logo />
</template>
<template #nav-left>
<template v-if="appStore.isMobile">
<Logo />
</template>
<template v-else>
<div v-if="!hasHorizontalMenu || !hidenCollapaseButton" class="h-full flex-y-center gap-1 p-x-sm">
<CollapaseButton v-if="!hidenCollapaseButton" />
<Breadcrumb v-if="!hasHorizontalMenu" />
</div>
</template>
</template>
<template #nav-center>
<div class="h-full flex-y-center gap-1">
<n-menu v-if="hasHorizontalMenu" v-bind="layout.horizontalMenuProps" />
</div>
</template>
<template #nav-right>
<div class="h-full flex-y-center gap-1 p-x-xl">
<!-- 移动端只显示菜单按钮 -->
<template v-if="appStore.isMobile">
<n-button
quaternary
@click="showMobileDrawer = true"
>
<template #icon>
<n-icon size="18">
<icon-park-outline-hamburger-button />
</n-icon>
</template>
</n-button>
</template>
<!-- 桌面端显示完整功能组件 -->
<template v-else>
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</template>
</div>
</template>
<template #sidebar>
<n-menu v-bind="layout.verticalMenuProps" :collapsed-width="sidebarCollapsedWidth" />
</template>
<template #sidebar-extra>
<n-scrollbar class="flex-[1_0_0]">
<n-menu v-bind="layout.verticalExtraMenuProps" :collapsed-width="sidebarCollapsedWidth" />
</n-scrollbar>
</template>
<template #tabbar>
<TabBar />
</template>
<template #footer>
<div class="flex-center h-full">
{{ appStore.footerText }}
</div>
</template>
<Content />
<BackTop />
<SettingDrawer />
<!-- 移动端功能抽屉 -->
<MobileDrawer v-model:show="showMobileDrawer">
<n-menu v-bind="layout.verticalMenuProps" />
</MobileDrawer>
</ProLayout>
</template> </template>

View File

@ -1,94 +0,0 @@
<script lang="ts" setup>
import {
BackTop,
Breadcrumb,
CollapaseButton,
FullScreen,
Logo,
Menu,
Notices,
Search,
Setting,
TabBar,
UserCenter,
} from './components'
import { useAppStore, useRouteStore } from '@/store'
const routeStore = useRouteStore()
const appStore = useAppStore()
</script>
<template>
<n-layout
has-sider
class="wh-full"
embedded
>
<n-layout-sider
bordered
:collapsed="appStore.collapsed"
collapse-mode="width"
:collapsed-width="64"
:width="240"
content-style="display: flex;flex-direction: column;min-height:100%;"
>
<Logo v-if="appStore.showLogo" />
<n-scrollbar class="flex-1">
<Menu />
</n-scrollbar>
</n-layout-sider>
<n-layout
class="h-full flex flex-col"
content-style="display: flex;flex-direction: column;min-height:100%;"
embedded
:native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-1">
<div class="h-60px flex-y-center justify-between">
<div class="flex-y-center h-full">
<CollapaseButton />
<Breadcrumb />
</div>
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<div class="flex-1 p-16px flex flex-col">
<div class="h-60px" />
<div v-if="appStore.showTabs" class="h-45px" />
<router-view v-slot="{ Component, route }" class="flex-1">
<transition
:name="appStore.transitionAnimation"
mode="out-in"
>
<keep-alive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="appStore.loadFlag"
:key="route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
<div v-if="appStore.showFooter" class="h-40px" />
</div>
<n-layout-footer
v-if="appStore.showFooter"
bordered
position="absolute"
class="h-40px flex-center"
>
{{ appStore.footerText }}
</n-layout-footer>
<BackTop />
</n-layout>
</n-layout>
</template>

View File

@ -1,59 +0,0 @@
<script lang="ts" setup>
import {
BackTop,
FullScreen,
Logo,
Menu,
Notices,
Search,
Setting,
TabBar,
UserCenter,
} from './components'
import { useAppStore, useRouteStore } from '@/store'
const routeStore = useRouteStore()
const appStore = useAppStore()
</script>
<template>
<n-layout class="wh-full" embedded>
<n-layout
class="h-full flex flex-col" content-style="display: flex;flex-direction: column;min-height:100%;"
embedded :native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-1">
<div class="h-60px flex-y-center justify-between shrink-0">
<Logo v-if="appStore.showLogo" />
<Menu mode="horizontal" responsive />
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<div class="flex-1 p-16px flex flex-col">
<div class="h-60px" />
<div v-if="appStore.showTabs" class="h-45px" />
<router-view v-slot="{ Component, route }" class="flex-1">
<transition :name="appStore.transitionAnimation" mode="out-in">
<keep-alive :include="routeStore.cacheRoutes">
<component :is="Component" v-if="appStore.loadFlag" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
<div v-if="appStore.showFooter" class="h-40px" />
</div>
<n-layout-footer v-if="appStore.showFooter" bordered position="absolute" class="h-40px flex-center">
{{ appStore.footerText }}
</n-layout-footer>
<BackTop />
</n-layout>
</n-layout>
</template>

View File

@ -1,35 +1,5 @@
import type { App } from 'vue' import App from './App.vue'
import AppVue from './App.vue'
import AppLoading from './components/common/AppLoading.vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
async function setupApp() { // 创建应用实例并挂载
// 载入全局loading加载状态 const app = createApp(App)
const appLoading = createApp(AppLoading)
appLoading.mount('#appLoading')
// 创建vue实例
const app = createApp(AppVue)
// 注册模块Pinia
await installPinia(app)
// 注册模块 Vue-router
await installRouter(app)
/* 注册模块 指令/静态资源 */
Object.values(
import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
eager: true,
}),
).map(i => app.use(i))
// 卸载载入动画
appLoading.unmount()
// 挂载
app.mount('#app') app.mount('#app')
}
setupApp()

View File

@ -1,17 +1,24 @@
import { createI18n } from 'vue-i18n'
import type { App } from 'vue' import type { App } from 'vue'
import { local } from '@/utils'
import { createI18n } from 'vue-i18n'
import enUS from '../../locales/en_US.json' import enUS from '../../locales/en_US.json'
import zhCN from '../../locales/zh_CN.json' import zhCN from '../../locales/zh_CN.json'
import { local } from '@/utils'
const { VITE_DEFAULT_LANG } = import.meta.env
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: local.get('lang') || 'enUS', // 默认显示语言 locale: local.get('lang') || VITE_DEFAULT_LANG, // 默认显示语言
fallbackLocale: 'enUS', fallbackLocale: VITE_DEFAULT_LANG,
messages: { messages: {
zhCN, zhCN,
enUS, enUS,
}, },
// 缺失国际化键警告
// missingWarn: false,
// 缺失回退内容警告
fallbackWarn: false,
}) })
export function install(app: App) { export function install(app: App) {

View File

@ -11,31 +11,53 @@ export function setupRouterGuard(router: Router) {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
// 判断是否是外链,如果是直接打开网页并拦截跳转 // 判断是否是外链,如果是直接打开网页并拦截跳转
if (to.meta.herf) { if (to.meta.href) {
window.open(to.meta.herf) window.open(to.meta.href)
return false next(false) // 取消当前导航
return
} }
// 开始 loadingBar // 开始 loadingBar
appStore.showProgress && window.$loadingBar?.start() appStore.showProgress && window.$loadingBar?.start()
// 判断有无TOKEN,登录鉴权 // 判断有无TOKEN,登录鉴权
const isLogin = Boolean(local.get('accessToken')) const isLogin = Boolean(local.get('accessToken'))
if (!isLogin) {
if (to.name === 'login')
next()
if (to.name !== 'login') { // 处理根路由重定向
const redirect = to.name === '404' ? undefined : to.fullPath if (to.name === 'root') {
next({ path: '/login', query: { redirect } }) if (isLogin) {
// 已登录,重定向到首页
next({ path: import.meta.env.VITE_HOME_PATH, replace: true })
} }
return false else {
// 未登录,重定向到登录页
next({ path: '/login', replace: true })
}
return
}
// 如果是login路由直接放行
if (to.name === 'login') {
// login页面不需要任何认证检查直接放行
// 继续执行后面的逻辑
}
// 如果路由明确设置了requiresAuth为false直接放行
else if (to.meta.requiresAuth === false) {
// 明确设置为false的路由直接放行
// 继续执行后面的逻辑
}
// 如果路由设置了requiresAuth为true且用户未登录重定向到登录页
else if (to.meta.requiresAuth === true && !isLogin) {
const redirect = to.name === 'not-found' ? undefined : to.fullPath
next({ path: '/login', query: { redirect } })
return
} }
// 判断路由有无进行初始化 // 判断路由有无进行初始化
if (!routeStore.isInitAuthRoute) { if (!routeStore.isInitAuthRoute && to.name !== 'login') {
try {
await routeStore.initAuthRoute() await routeStore.initAuthRoute()
// 动态路由加载完回到根路由 // 动态路由加载完回到根路由
if (to.name === '404') { if (to.name === 'not-found') {
// 等待权限路由加载好了,回到之前的路由,否则404 // 等待权限路由加载好了,回到之前的路由,否则404
next({ next({
path: to.fullPath, path: to.fullPath,
@ -43,14 +65,21 @@ export function setupRouterGuard(router: Router) {
query: to.query, query: to.query,
hash: to.hash, hash: to.hash,
}) })
return false return
}
}
catch {
// 如果路由初始化失败(比如 401 错误),重定向到登录页
const redirect = to.fullPath !== '/' ? to.fullPath : undefined
next({ path: '/login', query: redirect ? { redirect } : undefined })
return
} }
} }
// 判断当前页是否在login,则定位去首页 // 如果用户已登录且访问login页面重定向到首页
if (to.name === 'login') { if (to.name === 'login' && isLogin) {
next({ path: '/' }) next({ path: '/' })
return false return
} }
next() next()
@ -61,7 +90,7 @@ export function setupRouterGuard(router: Router) {
// 添加tabs // 添加tabs
tabStore.addTab(to) tabStore.addTab(to)
// 设置高亮标签; // 设置高亮标签;
tabStore.setCurrentTab(to.path as string) tabStore.setCurrentTab(to.fullPath as string)
}) })
router.afterEach((to) => { router.afterEach((to) => {

View File

@ -1,7 +1,7 @@
import type { App } from 'vue' import type { App } from 'vue'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router' import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { routes } from './routes.inner'
import { setupRouterGuard } from './guard' import { setupRouterGuard } from './guard'
import { routes } from './routes.inner'
const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env const { VITE_ROUTE_MODE = 'hash', VITE_BASE_URL } = import.meta.env
export const router = createRouter({ export const router = createRouter({

View File

@ -5,53 +5,42 @@ export const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
name: 'root', name: 'root',
redirect: '/appRoot',
// component: () => import('@/layouts/index'),
children: [ children: [
], ],
}, },
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: () => import('@/views/login/index.vue'), // 注意这里要带上 文件后缀.vue component: () => import('@/views/build-in/login/index.vue'), // 注意这里要带上 文件后缀.vue
meta: { meta: {
title: '登录', title: '登录',
withoutTab: true, withoutTab: true,
}, },
}, },
{ {
path: '/403', path: '/public',
name: '403', name: 'publicAccess',
component: () => import('@/views/error/403/index.vue'), component: () => import('@/views/build-in/public-access/index.vue'),
meta: { meta: {
title: '用户无权限', title: '公共访问示例',
requiresAuth: false,
withoutTab: true, withoutTab: true,
}, },
}, },
{ {
path: '/404', path: '/not-found',
name: '404', name: 'not-found',
component: () => import('@/views/error/404/index.vue'), component: () => import('@/views/build-in/not-found/index.vue'),
meta: { meta: {
title: '找不到页面', title: '找不到页面',
icon: 'icon-park-outline:ghost', icon: 'icon-park-outline:ghost',
withoutTab: true, withoutTab: true,
}, },
}, },
{
path: '/500',
name: '500',
component: () => import('@/views/error/500/index.vue'),
meta: {
title: '服务器错误',
icon: 'icon-park-outline:close-wifi',
withoutTab: true,
},
},
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
component: () => import('@/views/error/404/index.vue'), component: () => import('@/views/build-in/not-found/index.vue'),
name: '404', name: 'not-found',
meta: { meta: {
title: '找不到页面', title: '找不到页面',
icon: 'icon-park-outline:ghost', icon: 'icon-park-outline:ghost',

View File

@ -1,407 +1,418 @@
export const staticRoutes: AppRoute.RowRoute[] = [ export const staticRoutes: AppRoute.RowRoute[] = [
{ {
'name': 'dashboard', name: 'dashboard',
'path': '/dashboard', path: '/dashboard',
'meta.title': '仪表盘', title: '仪表盘',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:analysis', icon: 'icon-park-outline:analysis',
'meta.menuType': 'dir', menuType: 'dir',
'componentPath': null, componentPath: null,
'id': 1, id: 1,
'pid': null, pid: null,
}, },
{ {
'name': 'workbench', name: 'workbench',
'path': '/dashboard/workbench', path: '/dashboard/workbench',
'meta.title': '工作台', title: '工作台',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:alarm', icon: 'icon-park-outline:alarm',
'meta.pinTab': true, pinTab: true,
'meta.menuType': 'page', menuType: 'page',
'componentPath': '/dashboard/workbench/index.vue', componentPath: '/dashboard/workbench/index.vue',
'id': 2, id: 101,
'pid': 1, pid: 1,
}, },
{ {
'name': 'monitor', name: 'monitor',
'path': '/dashboard/monitor', path: '/dashboard/monitor',
'meta.title': '监控页', title: '监控页',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:anchor', icon: 'icon-park-outline:anchor',
'meta.menuType': 'page', menuType: 'page',
'componentPath': '/dashboard/monitor/index.vue', componentPath: '/dashboard/monitor/index.vue',
'id': 3, id: 102,
'pid': 1, pid: 1,
}, },
{ {
'name': 'test', name: 'multi',
'path': '/test', path: '/multi',
'meta.title': '多级菜单演示', title: '多级菜单演示',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:list', icon: 'icon-park-outline:list',
'meta.menuType': 'dir', menuType: 'dir',
'componentPath': null, componentPath: null,
'id': 4, id: 2,
'pid': null, pid: null,
}, },
{ {
'name': 'test2', name: 'multi2',
'path': '/test/test2', path: '/multi/multi-2',
'meta.title': '多级菜单子页', title: '多级菜单子页',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:list', icon: 'icon-park-outline:list',
'meta.menuType': 'page', menuType: 'page',
'componentPath': '/test/test2/index.vue', componentPath: '/demo/multi/multi-2/index.vue',
'id': 6, id: 201,
'pid': 4, pid: 2,
}, },
{ {
'name': 'test2Detail', name: 'multi2-detail',
'path': '/test/test2/detail', path: '/multi/multi-2/detail',
'meta.title': '多级菜单的详情页', title: '菜单详情页',
'meta.requiresAuth': true, requiresAuth: false,
'meta.icon': 'icon-park-outline:list', icon: 'icon-park-outline:list',
'meta.hide': true, hide: true,
'meta.activeMenu': '/test/test2', activeMenu: '/multi/multi-2',
'meta.menuType': 'page', menuType: 'page',
'componentPath': '/test/test2/detail/index.vue', componentPath: '/demo/multi/multi-2/detail/index.vue',
'id': 7, id: 20101,
'pid': 4, pid: 2,
}, },
{ {
'name': 'test3', name: 'multi3',
'path': '/test/test3', path: '/multi/multi-3',
'meta.title': '多级菜单', title: '多级菜单',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:list', icon: 'icon-park-outline:list',
'meta.menuType': 'dir', menuType: 'dir',
'componentPath': null, componentPath: null,
'id': 8, id: 202,
'pid': 4, pid: 2,
}, },
{ {
'name': 'test4', name: 'multi4',
'path': '/test/test3/test4', path: '/multi/multi-3/multi-4',
'meta.title': '多级菜单3-1', title: '多级菜单3-1',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:list', icon: 'icon-park-outline:list',
'componentPath': '/test/test3/test4/index.vue', componentPath: '/demo/multi/multi-3/multi-4/index.vue',
'id': 9, id: 20201,
'pid': 8, pid: 202,
}, },
{ {
'name': 'list', name: 'list',
'path': '/list', path: '/list',
'meta.title': '列表页', title: '列表页',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:list-two', icon: 'icon-park-outline:list-two',
'meta.menuType': 'dir', menuType: 'dir',
'componentPath': null, componentPath: null,
'id': 10, id: 3,
'pid': null, pid: null,
}, },
{ {
'name': 'commonList', name: 'commonList',
'path': '/list/commonList', path: '/list/common-list',
'meta.title': '常用列表', title: '常用列表',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:list-view', icon: 'icon-park-outline:list-view',
'componentPath': '/list/commonList/index.vue', componentPath: '/demo/list/common-list/index.vue',
'id': 11, id: 301,
'pid': 10, pid: 3,
}, },
{ {
'name': 'cardList', name: 'cardList',
'path': '/list/cardList', path: '/list/card-list',
'meta.title': '卡片列表', title: '卡片列表',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:view-grid-list', icon: 'icon-park-outline:view-grid-list',
'componentPath': '/list/cardList/index.vue', componentPath: '/demo/list/card-list/index.vue',
'id': 12, id: 302,
'pid': 10, pid: 3,
}, },
{ {
'name': 'demo', name: 'draggableList',
'path': '/demo', path: '/list/draggable-list',
'meta.title': '功能示例', title: '拖拽列表',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:application-one', icon: 'icon-park-outline:menu-fold',
'meta.menuType': 'dir', componentPath: '/demo/list/draggable-list/index.vue',
'componentPath': null, id: 303,
'id': 13, pid: 3,
'pid': null,
}, },
{ {
'name': 'fetch', name: 'demo',
'path': '/demo/fetch', path: '/demo',
'meta.title': '请求示例', title: '功能示例',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:international', icon: 'icon-park-outline:application-one',
'componentPath': '/demo/fetch/index.vue', menuType: 'dir',
'id': 5, componentPath: null,
'pid': 13, id: 4,
pid: null,
}, },
{ {
'name': 'echarts', name: 'fetch',
'path': '/demo/echarts', path: '/demo/fetch',
'meta.title': 'ECharts', title: '请求示例',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:chart-proportion', icon: 'icon-park-outline:international',
'componentPath': '/demo/echarts/index.vue', componentPath: '/demo/fetch/index.vue',
'id': 15, id: 401,
'pid': 13, pid: 4,
}, },
{ {
'name': 'map', name: 'echarts',
'path': '/demo/map', path: '/demo/echarts',
'meta.title': '地图', title: 'ECharts',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'carbon:map', icon: 'icon-park-outline:chart-proportion',
'meta.keepAlive': true, componentPath: '/demo/echarts/index.vue',
'componentPath': '/demo/map/index.vue', id: 402,
'id': 17, pid: 4,
'pid': 13,
}, },
{ {
'name': 'editor', name: 'map',
'path': '/demo/editor', path: '/demo/map',
'meta.title': '编辑器', title: '地图',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:editor', icon: 'carbon:map',
'meta.menuType': 'dir', keepAlive: true,
'componentPath': null, componentPath: '/demo/map/index.vue',
'id': 18, id: 403,
'pid': 13, pid: 4,
}, },
{ {
'name': 'editorMd', name: 'editor',
'path': '/demo/editor/md', path: '/demo/editor',
'meta.title': 'MarkDown', title: '编辑器',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'ri:markdown-line', icon: 'icon-park-outline:editor',
'componentPath': '/demo/editor/md/index.vue', menuType: 'dir',
'id': 19, componentPath: null,
'pid': 18, id: 404,
pid: 4,
}, },
{ {
'name': 'editorRich', name: 'editorMd',
'path': '/demo/editor/rich', path: '/demo/editor/md',
'meta.title': '富文本', title: 'MarkDown',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:edit-one', icon: 'ri:markdown-line',
'componentPath': '/demo/editor/rich/index.vue', componentPath: '/demo/editor/md/index.vue',
'id': 20, id: 40401,
'pid': 18, pid: 404,
}, },
{ {
'name': 'clipboard', name: 'editorRich',
'path': '/demo/clipboard', path: '/demo/editor/rich',
'meta.title': '剪贴板', title: '富文本',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:clipboard', icon: 'icon-park-outline:edit-one',
'componentPath': '/demo/clipboard/index.vue', componentPath: '/demo/editor/rich/index.vue',
'id': 21, id: 40402,
'pid': 13, pid: 404,
}, },
{ {
'name': 'icons', name: 'clipboard',
'path': '/demo/icons', path: '/demo/clipboard',
'meta.title': '图标', title: '剪贴板',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:winking-face-with-open-eyes', icon: 'icon-park-outline:clipboard',
'componentPath': '/demo/icons/index.vue', componentPath: '/demo/clipboard/index.vue',
'id': 22, id: 405,
'pid': 13, pid: 4,
}, },
{ {
'name': 'QRCode', name: 'icons',
'path': '/demo/QRCode', path: '/demo/icons',
'meta.title': '二维码', title: '图标',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:two-dimensional-code', icon: 'local:cool',
'componentPath': '/demo/QRCode/index.vue', componentPath: '/demo/icons/index.vue',
'id': 23, id: 406,
'pid': 13, pid: 4,
}, },
{ {
'name': 'docments', name: 'QRCode',
'path': '/docments', path: '/demo/qr-code',
'meta.title': '外链文档', title: '二维码',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:file-doc', icon: 'icon-park-outline:two-dimensional-code',
'meta.menuType': 'dir', componentPath: '/demo/qr-code/index.vue',
'componentPath': null, id: 407,
'id': 24, pid: 4,
'pid': null,
}, },
{ {
'name': 'docmentsVue', name: 'cascader',
'path': '/docments/vue', path: '/demo/cascader',
'meta.title': 'Vue', title: '省市区联动',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'logos:vue', icon: 'icon-park-outline:add-subset',
'componentPath': '/docments/vue/index.vue', componentPath: '/demo/cascader/index.vue',
'id': 25, id: 408,
'pid': 24, pid: 4,
}, },
{ {
'name': 'docmentsVite', name: 'dict',
'path': '/docments/vite', path: '/demo/dict',
'meta.title': 'Vite', title: '字典示例',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'logos:vitejs', icon: 'icon-park-outline:book-one',
'componentPath': '/docments/vite/index.vue', componentPath: '/demo/dict/index.vue',
'id': 26, id: 409,
'pid': 24, pid: 4,
}, },
{ {
'name': 'docmentsVueuse', name: 'documents',
'path': '/docments/vueuse', path: '/documents',
'meta.title': 'VueUse外链', title: '外链文档',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'logos:vueuse', icon: 'icon-park-outline:file-doc',
'meta.herf': 'https://vueuse.org/guide/', menuType: 'dir',
'componentPath': 'null', componentPath: null,
'id': 27, id: 5,
'pid': 24, pid: null,
}, },
{ {
'name': 'permission', name: 'documentsVue',
'path': '/permission', path: '/documents/vue',
'meta.title': '权限', title: 'Vue',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:people-safe', icon: 'logos:vue',
'meta.menuType': 'dir', componentPath: '/demo/documents/vue/index.vue',
'componentPath': null, id: 501,
'id': 28, pid: 5,
'pid': null,
}, },
{ {
'name': 'permissionDemo', name: 'documentsVite',
'path': '/permission/permission', path: '/documents/vite',
'meta.title': '权限示例', title: 'Vite',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:right-user', icon: 'logos:vitejs',
'componentPath': '/permission/permission/index.vue', componentPath: '/demo/documents/vite/index.vue',
'id': 29, id: 502,
'pid': 28, pid: 5,
}, },
{ {
'name': 'justSuper', name: 'documentsVueuse',
'path': '/permission/justSuper', path: '/documents/vue-use',
'meta.title': 'super可见', title: 'VueUse外链',
'meta.requiresAuth': true, requiresAuth: true,
'meta.roles': [ icon: 'logos:vueuse',
href: 'https://vueuse.org/guide/',
componentPath: 'null',
id: 503,
pid: 5,
},
{
name: 'documentsNova',
path: '/documents/nova',
title: 'Nova docs',
requiresAuth: true,
icon: 'local:logo',
href: 'https://nova-admin-docs.netlify.app/',
componentPath: '2333333',
id: 504,
pid: 5,
},
{
name: 'documentsPublic',
path: '/documents/public',
title: '公共示例页(外链)',
requiresAuth: true,
icon: 'local:logo',
href: '/public',
componentPath: 'null',
id: 505,
pid: 5,
},
{
name: 'permission',
path: '/permission',
title: '权限',
requiresAuth: true,
icon: 'icon-park-outline:people-safe',
menuType: 'dir',
componentPath: null,
id: 6,
pid: null,
},
{
name: 'permissionDemo',
path: '/permission/permission',
title: '权限示例',
requiresAuth: true,
icon: 'icon-park-outline:right-user',
componentPath: '/demo/permission/permission/index.vue',
id: 601,
pid: 6,
},
{
name: 'justSuper',
path: '/permission/just-super',
title: 'super可见',
requiresAuth: true,
roles: [
'super', 'super',
], ],
'meta.icon': 'icon-park-outline:wrong-user', icon: 'icon-park-outline:wrong-user',
'componentPath': '/permission/justSuper/index.vue', componentPath: '/demo/permission/just-super/index.vue',
'id': 30, id: 602,
'pid': 28, pid: 6,
}, },
{ {
'name': 'error', name: 'setting',
'path': '/error', path: '/setting',
'meta.title': '异常页', title: '系统设置',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:error-computer', icon: 'icon-park-outline:setting',
'meta.menuType': 'dir', menuType: 'dir',
'componentPath': null, componentPath: null,
'id': 31, id: 7,
'pid': null, pid: null,
}, },
{ {
'name': 'demo403', name: 'accountSetting',
'path': '/error/403', path: '/setting/account',
'meta.title': '403', title: '用户设置',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'carbon:error', icon: 'icon-park-outline:every-user',
'meta.order': 3, componentPath: '/setting/account/index.vue',
'componentPath': '/error/403/index.vue', id: 701,
'id': 32, pid: 7,
'pid': 31,
}, },
{ {
'name': 'demo404', name: 'dictionarySetting',
'path': '/error/404', path: '/setting/dictionary',
'meta.title': '404', title: '字典设置',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:error', icon: 'icon-park-outline:book-one',
'meta.order': 2, componentPath: '/setting/dictionary/index.vue',
'componentPath': '/error/404/index.vue', id: 702,
'id': 33, pid: 7,
'pid': 31,
}, },
{ {
'name': 'demo500', name: 'menuSetting',
'path': '/error/500', path: '/setting/menu',
'meta.title': '500', title: '菜单设置',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'carbon:data-error', icon: 'icon-park-outline:application-menu',
'meta.order': 1, componentPath: '/setting/menu/index.vue',
'componentPath': '/error/500/index.vue', id: 703,
'id': 34, pid: 7,
'pid': 31,
}, },
{ {
'name': 'setting', name: 'about',
'path': '/setting', path: '/about',
'meta.title': '系统设置', title: '关于',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:setting', icon: 'icon-park-outline:info',
'meta.menuType': 'dir', componentPath: '/demo/about/index.vue',
'componentPath': null, id: 8,
'id': 35, pid: null,
'pid': null,
}, },
{ {
'name': 'accountSetting', name: 'userCenter',
'path': '/setting/account', path: '/user-center',
'meta.title': '用户设置', title: '个人中心',
'meta.requiresAuth': true, requiresAuth: true,
'meta.icon': 'icon-park-outline:every-user', hide: true,
'componentPath': '/setting/account/index.vue', icon: 'carbon:user-avatar-filled-alt',
'id': 36, componentPath: '/build-in/user-center/index.vue',
'pid': 35, id: 999,
}, pid: null,
{
'name': 'dictionarySetting',
'path': '/setting/dictionary',
'meta.title': '字典设置',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:book-one',
'componentPath': '/setting/dictionary/index.vue',
'id': 37,
'pid': 35,
},
{
'name': 'menuSetting',
'path': '/setting/menu',
'meta.title': '菜单设置',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:application-menu',
'componentPath': '/setting/menu/index.vue',
'id': 38,
'pid': 35,
},
{
'name': 'userCenter',
'path': '/userCenter',
'meta.title': '个人中心',
'meta.requiresAuth': true,
'meta.icon': 'carbon:user-avatar-filled-alt',
'componentPath': '/userCenter/index.vue',
'id': 39,
'pid': null,
},
{
'name': 'about',
'path': '/about',
'meta.title': '关于',
'meta.requiresAuth': true,
'meta.icon': 'icon-park-outline:info',
'componentPath': '/about/index.vue',
'id': 40,
'pid': null,
}, },
] ]

View File

@ -5,15 +5,15 @@ interface Ilogin {
password: string password: string
} }
export function fetchLogin(params: Ilogin) { export function fetchLogin(data: Ilogin) {
const methodInstance = request.Post<Service.ResponseResult<ApiAuth.loginInfo>>('/login', params) const methodInstance = request.Post<Service.ResponseResult<Api.Login.Info>>('/login', data)
methodInstance.meta = { methodInstance.meta = {
authRole: null, authRole: null,
} }
return methodInstance return methodInstance
} }
export function fetchUpdateToken(data: any) { export function fetchUpdateToken(data: any) {
const method = request.Post<Service.ResponseResult<ApiAuth.loginInfo>>('/updateToken', data) const method = request.Post<Service.ResponseResult<Api.Login.Info>>('/updateToken', data)
method.meta = { method.meta = {
authRole: 'refreshToken', authRole: 'refreshToken',
} }

View File

@ -1,5 +1,26 @@
import { request } from '../http' import { request } from '../http'
// 获取所有路由信息
export function fetchAllRoutes() { export function fetchAllRoutes() {
return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes') return request.Get<Service.ResponseResult<AppRoute.RowRoute[]>>('/getUserRoutes')
} }
// 获取所有用户信息
export function fetchUserPage() {
return request.Get<Service.ResponseResult<Entity.User[]>>('/userPage')
}
// 获取所有角色列表
export function fetchRoleList() {
return request.Get<Service.ResponseResult<Entity.Role[]>>('/role/list')
}
/**
*
*
* @param code -
* @returns
*/
export function fetchDictList(code?: string) {
const params = { code }
return request.Get<Service.ResponseResult<Entity.Dict[]>>('/dict/list', { params })
}

View File

@ -1,7 +1,7 @@
import { blankInstance, request } from '../http' import { blankInstance, request } from '../http'
/* get方法测试 */ /* get方法测试 */
export function fetachGet(params?: any) { export function fetchGet(params?: any) {
return request.Get('/getAPI', { params }) return request.Get('/getAPI', { params })
} }
@ -36,7 +36,7 @@ export function withoutToken() {
/* 接口数据转换 */ /* 接口数据转换 */
export function dictData() { export function dictData() {
return request.Get('/getDictData', { return request.Get('/getDictData', {
transformData(rawData, _headers) { transform(rawData, _headers) {
const response = rawData as any const response = rawData as any
return { return {
...response, ...response,
@ -53,7 +53,7 @@ export function dictData() {
export function getBlob(url: string) { export function getBlob(url: string) {
const methodInstance = blankInstance.Get<Blob>(url) const methodInstance = blankInstance.Get<Blob>(url)
methodInstance.meta = { methodInstance.meta = {
// 标识为bolb数据 // 标识为blob数据
isBlob: true, isBlob: true,
} }
return methodInstance return methodInstance
@ -61,12 +61,9 @@ export function getBlob(url: string) {
/* 带进度的下载文件 */ /* 带进度的下载文件 */
export function downloadFile(url: string) { export function downloadFile(url: string) {
const methodInstance = blankInstance.Get<Blob>(url, { const methodInstance = blankInstance.Get<Blob>(url)
// 开启下载进度
enableDownload: true,
})
methodInstance.meta = { methodInstance.meta = {
// 标识为bolb数据 // 标识为blob数据
isBlob: true, isBlob: true,
} }
return methodInstance return methodInstance

View File

@ -1,30 +1,39 @@
import { local } from '@/utils'
import { createAlova } from 'alova' import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import adapterFetch from 'alova/fetch'
import VueHook from 'alova/vue' import VueHook from 'alova/vue'
import GlobalFetch from 'alova/GlobalFetch' import type { VueHookType } from 'alova/vue'
import { createServerTokenAuthentication } from '@alova/scene-vue' import {
import qs from 'qs' DEFAULT_ALOVA_OPTIONS,
DEFAULT_BACKEND_OPTIONS,
} from './config'
import { import {
handleBusinessError, handleBusinessError,
handleRefreshToken, handleRefreshToken,
handleResponseError, handleResponseError,
handleServiceResult, handleServiceResult,
} from './handle' } from './handle'
import {
DEFAULT_ALOVA_OPTIONS,
DEFAULT_BACKEND_OPTIONS,
} from './config'
import { local } from '@/utils'
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication({ const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<VueHookType>({
// 服务端判定token过期 // 服务端判定token过期
refreshTokenOnSuccess: { refreshTokenOnSuccess: {
// 当服务端返回401时表示token过期 // 当服务端返回401时表示token过期
isExpired: (response, _method) => { isExpired: async (response, method) => {
return response.status === 401 const res = await response.clone().json()
const isExpired = method.meta && method.meta.isExpired
return (response.status === 401 || res.code === 401) && !isExpired
}, },
// 当token过期时触发在此函数中触发刷新token // 当token过期时触发在此函数中触发刷新token
handler: async (_response, _method) => { handler: async (_response, method) => {
// 此处采取限制,防止过期请求无限循环重发
if (!method.meta)
method.meta = { isExpired: true }
else
method.meta.isExpired = true
await handleRefreshToken() await handleRefreshToken()
}, },
}, },
@ -44,15 +53,15 @@ export function createAlovaInstance(
return createAlova({ return createAlova({
statesHook: VueHook, statesHook: VueHook,
requestAdapter: GlobalFetch(), requestAdapter: adapterFetch(),
localCache: null, cacheFor: null,
baseURL: _alovaConfig.baseURL, baseURL: _alovaConfig.baseURL,
timeout: _alovaConfig.timeout, timeout: _alovaConfig.timeout,
beforeRequest: onAuthRequired((method) => { beforeRequest: onAuthRequired((method) => {
if (method.meta?.isFormPost) { if (method.meta?.isFormPost) {
method.config.headers['Content-Type'] = 'application/x-www-form-urlencoded' method.config.headers['Content-Type'] = 'application/x-www-form-urlencoded'
method.data = qs.stringify(method.data) method.data = new URLSearchParams(method.data as URLSearchParams).toString()
} }
alovaConfig.beforeRequest?.(method) alovaConfig.beforeRequest?.(method)
}), }),

View File

@ -1,4 +1,5 @@
import { $t } from '@/utils' import { $t } from '@/utils'
/** 默认实例的Aixos配置 */ /** 默认实例的Aixos配置 */
export const DEFAULT_ALOVA_OPTIONS = { export const DEFAULT_ALOVA_OPTIONS = {
// 请求超时时间,默认15秒 // 请求超时时间,默认15秒

View File

@ -1,10 +1,10 @@
import { fetchUpdateToken } from '@/service'
import { useAuthStore } from '@/store'
import { local } from '@/utils'
import { import {
ERROR_NO_TIP_STATUS, ERROR_NO_TIP_STATUS,
ERROR_STATUS, ERROR_STATUS,
} from './config' } from './config'
import { useAuthStore } from '@/store'
import { fetchUpdateToken } from '@/service'
import { local } from '@/utils'
type ErrorStatus = keyof typeof ERROR_STATUS type ErrorStatus = keyof typeof ERROR_STATUS
@ -70,6 +70,13 @@ export function handleServiceResult(data: any, isSuccess: boolean = true) {
*/ */
export async function handleRefreshToken() { export async function handleRefreshToken() {
const authStore = useAuthStore() const authStore = useAuthStore()
const isAutoRefresh = import.meta.env.VITE_AUTO_REFRESH_TOKEN === 'Y'
if (!isAutoRefresh) {
await authStore.logout()
return
}
// 刷新token
const { data } = await fetchUpdateToken({ refreshToken: local.get('refreshToken') }) const { data } = await fetchUpdateToken({ refreshToken: local.get('refreshToken') })
if (data) { if (data) {
local.set('accessToken', data.accessToken) local.set('accessToken', data.accessToken)
@ -77,7 +84,7 @@ export async function handleRefreshToken() {
} }
else { else {
// 刷新失败,退出 // 刷新失败,退出
await authStore.resetAuthStore() await authStore.logout()
} }
} }

View File

@ -1,13 +1,7 @@
import { createAlovaInstance } from './alova' import { createAlovaInstance } from './alova'
import { serviceConfig } from '@/../service.config'
import { generateProxyPattern } from '@/../build/proxy'
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'
const { url } = generateProxyPattern(serviceConfig[import.meta.env.MODE])
export const request = createAlovaInstance({ export const request = createAlovaInstance({
baseURL: isHttpProxy ? url.proxy : url.value, baseURL: __URL_MAP__.url.path,
}) })
export const blankInstance = createAlovaInstance({ export const blankInstance = createAlovaInstance({

View File

@ -1,4 +1,4 @@
export * from './api/system'
export * from './api/login'
export * from './api/list' export * from './api/list'
export * from './api/login'
export * from './api/system'
export * from './api/test' export * from './api/test'

View File

@ -1,11 +1,13 @@
import type { GlobalThemeOverrides } from 'naive-ui' import type { GlobalThemeOverrides } from 'naive-ui'
import { local, setLocale } from '@/utils'
import { colord } from 'colord' import { colord } from 'colord'
import { set } from 'radash' import { set } from 'radash'
import themeConfig from './theme.json' import themeConfig from './theme.json'
import { local, setLocale } from '@/utils' import type { ProLayoutMode } from 'pro-naive-ui'
type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out' export type TransitionAnimation = '' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out'
export type LayoutMode = 'leftMenu' | 'topMenu'
const { VITE_DEFAULT_LANG, VITE_COPYRIGHT_INFO } = import.meta.env
const docEle = ref(document.documentElement) const docEle = ref(document.documentElement)
@ -15,11 +17,13 @@ const { system, store } = useColorMode({
emitAuto: true, emitAuto: true,
}) })
const isMobile = useMediaQuery('(max-width: 700px)')
export const useAppStore = defineStore('app-store', { export const useAppStore = defineStore('app-store', {
state: () => { state: () => {
return { return {
footerText: 'Copyright © 2024 chansee97', footerText: VITE_COPYRIGHT_INFO,
lang: 'enUS' as App.lang, lang: VITE_DEFAULT_LANG,
theme: themeConfig as GlobalThemeOverrides, theme: themeConfig as GlobalThemeOverrides,
primaryColor: themeConfig.common.primaryColor, primaryColor: themeConfig.common.primaryColor,
collapsed: false, collapsed: false,
@ -33,8 +37,9 @@ export const useAppStore = defineStore('app-store', {
showBreadcrumb: true, showBreadcrumb: true,
showBreadcrumbIcon: true, showBreadcrumbIcon: true,
showWatermark: false, showWatermark: false,
showSetting: false,
transitionAnimation: 'fade-slide' as TransitionAnimation, transitionAnimation: 'fade-slide' as TransitionAnimation,
layoutMode: 'leftMenu' as LayoutMode, layoutMode: 'vertical' as ProLayoutMode,
} }
}, },
getters: { getters: {
@ -47,6 +52,9 @@ export const useAppStore = defineStore('app-store', {
fullScreen() { fullScreen() {
return isFullscreen.value return isFullscreen.value
}, },
isMobile() {
return isMobile.value
},
}, },
actions: { actions: {
// 重置所有设置 // 重置所有设置
@ -59,13 +67,12 @@ export const useAppStore = defineStore('app-store', {
this.loadFlag = true this.loadFlag = true
this.showLogo = true this.showLogo = true
this.showTabs = true this.showTabs = true
this.showLogo = true
this.showFooter = true this.showFooter = true
this.showBreadcrumb = true this.showBreadcrumb = true
this.showBreadcrumbIcon = true this.showBreadcrumbIcon = true
this.showWatermark = false this.showWatermark = false
this.transitionAnimation = 'fade-slide' this.transitionAnimation = 'fade-slide'
this.layoutMode = 'leftMenu' this.layoutMode = 'vertical'
// 重置所有配色 // 重置所有配色
this.setPrimaryColor(this.primaryColor) this.setPrimaryColor(this.primaryColor)
@ -77,7 +84,7 @@ export const useAppStore = defineStore('app-store', {
}, },
/* 设置主题色 */ /* 设置主题色 */
setPrimaryColor(color: string) { setPrimaryColor(color: string) {
const brightenColor = colord(color).lighten(0.1).toHex() const brightenColor = colord(color).lighten(0.05).toHex()
const darkenColor = colord(color).darken(0.05).toHex() const darkenColor = colord(color).darken(0.05).toHex()
set(this.theme, 'common.primaryColor', color) set(this.theme, 'common.primaryColor', color)
set(this.theme, 'common.primaryColorHover', brightenColor) set(this.theme, 'common.primaryColorHover', brightenColor)
@ -124,11 +131,6 @@ export const useAppStore = defineStore('app-store', {
}, },
}, },
persist: { persist: {
enabled: true,
strategies: [
{
storage: localStorage, storage: localStorage,
}, },
],
},
}) })

View File

@ -1,11 +1,11 @@
import { useRouteStore } from './route'
import { useTabStore } from './tab'
import { fetchLogin } from '@/service'
import { router } from '@/router' import { router } from '@/router'
import { fetchLogin } from '@/service'
import { local } from '@/utils' import { local } from '@/utils'
import { useRouteStore } from './router'
import { useTabStore } from './tab'
interface AuthStatus { interface AuthStatus {
userInfo: ApiAuth.loginInfo | null userInfo: Api.Login.Info | null
token: string token: string
} }
export const useAuthStore = defineStore('auth-store', { export const useAuthStore = defineStore('auth-store', {
@ -23,7 +23,7 @@ export const useAuthStore = defineStore('auth-store', {
}, },
actions: { actions: {
/* 登录退出,重置用户信息等 */ /* 登录退出,重置用户信息等 */
async resetAuthStore() { async logout() {
const route = unref(router.currentRoute) const route = unref(router.currentRoute)
// 清除本地缓存 // 清除本地缓存
this.clearAuthStorage() this.clearAuthStorage()
@ -33,7 +33,7 @@ export const useAuthStore = defineStore('auth-store', {
// 清空标签栏数据 // 清空标签栏数据
const tabStore = useTabStore() const tabStore = useTabStore()
tabStore.clearAllTabs() tabStore.clearAllTabs()
// 重当前存储库 // 重当前存储库
this.$reset() this.$reset()
// 重定向到登录页 // 重定向到登录页
if (route.meta.requiresAuth) { if (route.meta.requiresAuth) {
@ -53,16 +53,21 @@ export const useAuthStore = defineStore('auth-store', {
/* 用户登录 */ /* 用户登录 */
async login(userName: string, password: string) { async login(userName: string, password: string) {
try {
const { isSuccess, data } = await fetchLogin({ userName, password }) const { isSuccess, data } = await fetchLogin({ userName, password })
if (!isSuccess) if (!isSuccess)
return return
// 处理登录信息 // 处理登录信息
await this.handleAfterLogin(data) await this.handleLoginInfo(data)
}
catch (e) {
console.warn('[Login Error]:', e)
}
}, },
/* 登录后的处理函数 */ /* 处理登录返回的数据 */
async handleAfterLogin(data: ApiAuth.loginInfo) { async handleLoginInfo(data: Api.Login.Info) {
// 将token和userInfo保存下来 // 将token和userInfo保存下来
local.set('userInfo', data) local.set('userInfo', data)
local.set('accessToken', data.accessToken) local.set('accessToken', data.accessToken)

58
src/store/dict.ts Normal file
View File

@ -0,0 +1,58 @@
import { fetchDictList } from '@/service'
import { session } from '@/utils'
export const useDictStore = defineStore('dict-store', {
state: () => {
return {
dictMap: {} as DictMap,
isInitDict: false,
}
},
actions: {
async dict(code: string) {
// 调用前初始化
if (!this.dictMap) {
this.initDict()
}
const targetDict = await this.getDict(code)
return {
data: () => targetDict,
enum: () => Object.fromEntries(targetDict.map(({ value, label }) => [value, label])),
valueMap: () => Object.fromEntries(targetDict.map(({ value, ...data }) => [value, data])),
labelMap: () => Object.fromEntries(targetDict.map(({ label, ...data }) => [label, data])),
}
},
async getDict(code: string) {
const isExist = Reflect.has(this.dictMap, code)
if (isExist) {
return this.dictMap[code]
}
else {
return await this.getDictByNet(code)
}
},
async getDictByNet(code: string) {
const { data, isSuccess } = await fetchDictList(code)
if (isSuccess) {
Reflect.set(this.dictMap, code, data)
// 同步至session
session.set('dict', this.dictMap)
return data
}
else {
throw new Error(`Failed to get ${code} dictionary from network, check ${code} field or network`)
}
},
initDict() {
const dict = session.get('dict')
if (dict) {
Object.assign(this.dictMap, dict)
}
this.isInitDict = true
},
},
})

View File

@ -1,14 +1,15 @@
import type { App } from 'vue' import type { App } from 'vue'
import piniaPluginPersist from 'pinia-plugin-persist' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export * from './app/index' export * from './app/index'
export * from './auth' export * from './auth'
export * from './route' export * from './dict'
export * from './router'
export * from './tab' export * from './tab'
// 安装pinia全局状态库 // 安装pinia全局状态库
export function installPinia(app: App) { export function installPinia(app: App) {
const pinia = createPinia() const pinia = createPinia()
pinia.use(piniaPluginPersist) pinia.use(piniaPluginPersistedstate)
app.use(pinia) app.use(pinia)
} }

View File

@ -1,207 +0,0 @@
import type { MenuOption } from 'naive-ui'
import { RouterLink } from 'vue-router'
import { h } from 'vue'
import { clone, construct, min } from 'radash'
import type { RouteRecordRaw } from 'vue-router'
import { $t, arrayToTree, local, renderIcon } from '@/utils'
import { router } from '@/router'
import { fetchUserRoutes } from '@/service'
import { staticRoutes } from '@/router/routes.static'
import { usePermission } from '@/hooks'
import Layout from '@/layouts/index.vue'
import { useAuthStore } from '@/store/auth'
interface RoutesStatus {
isInitAuthRoute: boolean
menus: AppRoute.Route[]
rowRoutes: AppRoute.RowRoute[]
activeMenu: string | null
cacheRoutes: string[]
}
export const useRouteStore = defineStore('route-store', {
state: (): RoutesStatus => {
return {
isInitAuthRoute: false,
menus: [],
rowRoutes: [],
activeMenu: null,
cacheRoutes: [],
}
},
actions: {
resetRouteStore() {
this.resetRoutes()
this.$reset()
},
resetRoutes() {
router.removeRoute('appRoot')
},
// set the currently highlighted menu key
setActiveMenu(key: string) {
this.activeMenu = key
},
/* 生成侧边菜单的数据 */
createMenus(userRoutes: AppRoute.RowRoute[]) {
const resultMenus = clone(userRoutes).map(i => construct(i)) as AppRoute.Route[]
// filter menus that do not need to be displayed
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
// generate side menu
this.menus = arrayToTree(this.transformAuthRoutesToMenus(visibleMenus))
},
// render the returned routing table as a sidebar
transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]): MenuOption[] {
const { hasPermission } = usePermission()
// Filter out side menus without permission
return userRoutes.filter(i => hasPermission(i.meta.roles))
// Sort the menu according to the order size
.sort((a, b) => {
if (a.meta && a.meta.order && b.meta && b.meta.order)
return a.meta.order - b.meta.order
else if (a.meta && a.meta.order)
return -1
else if (b.meta && b.meta.order)
return 1
else return 0
})
// Convert to side menu data structure
.map((item) => {
const target: MenuOption = {
id: item.id,
pid: item.pid,
label:
(!item.meta.menuType || item.meta.menuType === 'page')
? () =>
h(
RouterLink,
{
to: {
path: item.path,
},
},
{ default: () => $t(`route.${String(item.name)}`, item.meta.title) },
)
: () => $t(`route.${String(item.name)}`, item.meta.title),
key: item.path,
icon: item.meta.icon ? renderIcon(item.meta.icon) : undefined,
}
return target
})
},
createRoutes(routes: AppRoute.RowRoute[]) {
const { hasPermission } = usePermission()
// Structure the meta field
let resultRouter = clone(routes).map(i => construct(i)) as AppRoute.Route[]
// Route permission filtering
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
// Generate an array of route names that need to be kept alive
this.cacheRoutes = resultRouter.filter((i) => {
return i.meta.keepAlive
})
.map(i => i.name)
// Generate routes, no need to import files for those with redirect
const modules = import.meta.glob('@/views/**/*.vue')
resultRouter = resultRouter.map((item: AppRoute.Route) => {
if (item.componentPath && !item.redirect)
item.component = modules[`/src/views${item.componentPath}`]
return item
})
// Generate route tree
resultRouter = arrayToTree(resultRouter) as AppRoute.Route[]
const appRootRoute: RouteRecordRaw = {
path: '/appRoot',
name: 'appRoot',
redirect: import.meta.env.VITE_HOME_PATH,
component: Layout,
meta: {
title: '',
icon: 'icon-park-outline:home',
},
children: [],
}
// Set the correct redirect path for the route
this.setRedirect(resultRouter)
// Insert the processed route into the root route
appRootRoute.children = resultRouter as unknown as RouteRecordRaw[]
router.addRoute(appRootRoute)
},
setRedirect(routes: AppRoute.Route[]) {
routes.forEach((route) => {
if (route.children) {
if (!route.redirect) {
// Filter out a collection of child elements that are not hidden
const visibleChilds = route.children.filter(child => !child.meta.hide)
// Redirect page to the path of the first child element by default
let target = visibleChilds[0]
// Filter out pages with the order attribute
const orderChilds = visibleChilds.filter(child => child.meta.order)
if (orderChilds.length > 0)
target = min(orderChilds, i => i.meta.order!) as AppRoute.Route
route.redirect = target.path
}
this.setRedirect(route.children)
}
})
},
async initRouteInfo() {
if (import.meta.env.VITE_AUTH_ROUTE_MODE === 'dynamic') {
const userInfo = local.get('userInfo')
if (!userInfo || !userInfo.id) {
const authStore = useAuthStore()
authStore.resetAuthStore()
return
}
// Get user's route
const { data } = await fetchUserRoutes({
id: userInfo.id,
})
if (!data)
return
return data
}
else {
this.rowRoutes = staticRoutes
return staticRoutes
}
},
async initAuthRoute() {
this.isInitAuthRoute = false
// Initialize route information
const rowRoutes = await this.initRouteInfo()
if (!rowRoutes) {
window.$message.error($t(`app.getRouteError`))
return
}
this.rowRoutes = rowRoutes
// Generate actual route and insert
this.createRoutes(rowRoutes)
// Generate side menu
this.createMenus(rowRoutes)
this.isInitAuthRoute = true
},
},
})

143
src/store/router/helper.ts Normal file
View File

@ -0,0 +1,143 @@
import type { MenuOption } from 'naive-ui'
import type { RouteRecordRaw } from 'vue-router'
import { usePermission } from '@/hooks'
import Layout from '@/layouts/index.vue'
import { $t, arrayToTree, renderIcon } from '@/utils'
import { clone, min, omit, pick } from 'radash'
import { RouterLink } from 'vue-router'
const metaFields: AppRoute.MetaKeys[]
= ['title', 'icon', 'requiresAuth', 'roles', 'keepAlive', 'hide', 'order', 'href', 'activeMenu', 'withoutTab', 'pinTab', 'menuType']
function standardizedRoutes(route: AppRoute.RowRoute[]) {
return clone(route).map((i) => {
const route = omit(i, metaFields)
Reflect.set(route, 'meta', pick(i, metaFields))
return route
}) as AppRoute.Route[]
}
export function createRoutes(routes: AppRoute.RowRoute[]) {
const { hasPermission } = usePermission()
// Structure the meta field
let resultRouter = standardizedRoutes(routes)
// Route permission filtering
resultRouter = resultRouter.filter(i => hasPermission(i.meta.roles))
// Generate routes, no need to import files for those with redirect
const modules = import.meta.glob('@/views/**/*.vue')
resultRouter = resultRouter.map((item: AppRoute.Route) => {
if (item.componentPath && !item.redirect)
item.component = modules[`/src/views${item.componentPath}`]
return item
})
// Generate route tree
resultRouter = arrayToTree(resultRouter) as AppRoute.Route[]
const appRootRoute: RouteRecordRaw = {
path: '/appRoot',
name: 'appRoot',
redirect: import.meta.env.VITE_HOME_PATH,
component: Layout,
meta: {
title: '',
icon: 'icon-park-outline:home',
},
children: [],
}
// Set the correct redirect path for the route
setRedirect(resultRouter)
// Insert the processed route into the root route
appRootRoute.children = resultRouter as unknown as RouteRecordRaw[]
return appRootRoute
}
// Generate an array of route names that need to be kept alive
export function generateCacheRoutes(routes: AppRoute.RowRoute[]) {
return routes
.filter(i => i.keepAlive)
.map(i => i.name)
}
function setRedirect(routes: AppRoute.Route[]) {
routes.forEach((route) => {
if (route.children) {
if (!route.redirect) {
// Filter out a collection of child elements that are not hidden
const visibleChilds = route.children.filter(child => !child.meta.hide)
// Redirect page to the path of the first child element by default
let target = visibleChilds[0]
// Filter out pages with the order attribute
const orderChilds = visibleChilds.filter(child => child.meta.order)
if (orderChilds.length > 0)
target = min(orderChilds, i => i.meta.order!) as AppRoute.Route
if (target)
route.redirect = target.path
}
setRedirect(route.children)
}
})
}
/* 生成侧边菜单的数据 */
export function createMenus(userRoutes: AppRoute.RowRoute[]) {
const resultMenus = standardizedRoutes(userRoutes)
// filter menus that do not need to be displayed
const visibleMenus = resultMenus.filter(route => !route.meta.hide)
// generate side menu
return arrayToTree(transformAuthRoutesToMenus(visibleMenus))
}
// render the returned routing table as a sidebar
function transformAuthRoutesToMenus(userRoutes: AppRoute.Route[]) {
const { hasPermission } = usePermission()
return userRoutes
// Filter out side menus without permission
.filter(i => hasPermission(i.meta.roles))
// Sort the menu according to the order size
.sort((a, b) => {
if (a.meta && a.meta.order && b.meta && b.meta.order)
return a.meta.order - b.meta.order
else if (a.meta && a.meta.order)
return -1
else if (b.meta && b.meta.order)
return 1
else return 0
})
// Convert to side menu data structure
.map((item) => {
const target: MenuOption = {
id: item.id,
pid: item.pid,
label:
(!item.meta.menuType || item.meta.menuType === 'page')
? () =>
h(
RouterLink,
{
to: {
path: item.path,
},
},
{ default: () => $t(`route.${String(item.name)}`, item.meta.title) },
)
: () => $t(`route.${String(item.name)}`, item.meta.title),
key: item.path,
icon: item.meta.icon ? renderIcon(item.meta.icon) : undefined,
}
return target
})
}

96
src/store/router/index.ts Normal file
View File

@ -0,0 +1,96 @@
import type { MenuOption } from 'naive-ui'
import { router } from '@/router'
import { staticRoutes } from '@/router/routes.static'
import { fetchUserRoutes } from '@/service'
import { useAuthStore } from '@/store/auth'
import { $t, local } from '@/utils'
import { createMenus, createRoutes, generateCacheRoutes } from './helper'
interface RoutesStatus {
isInitAuthRoute: boolean
menus: MenuOption[]
rowRoutes: AppRoute.RowRoute[]
activeMenu: string | null
cacheRoutes: string[]
}
export const useRouteStore = defineStore('route-store', {
state: (): RoutesStatus => {
return {
isInitAuthRoute: false,
activeMenu: null,
menus: [],
rowRoutes: [],
cacheRoutes: [],
}
},
actions: {
resetRouteStore() {
this.resetRoutes()
this.$reset()
},
resetRoutes() {
if (router.hasRoute('appRoot'))
router.removeRoute('appRoot')
},
// set the currently highlighted menu key
setActiveMenu(key: string) {
this.activeMenu = key
},
async initRouteInfo() {
if (import.meta.env.VITE_ROUTE_LOAD_MODE === 'dynamic') {
try {
// Get user's route
const result = await fetchUserRoutes({
id: 1,
})
if (!result.isSuccess || !result.data) {
throw new Error('Failed to fetch user routes')
}
return result.data
}
catch (error) {
console.error('Failed to initialize route info:', error)
throw error
}
}
else {
this.rowRoutes = staticRoutes
return staticRoutes
}
},
async initAuthRoute() {
this.isInitAuthRoute = false
try {
// Initialize route information
const rowRoutes = await this.initRouteInfo()
if (!rowRoutes) {
const error = new Error('Failed to get route information')
window.$message.error($t(`app.getRouteError`))
throw error
}
this.rowRoutes = rowRoutes
// Generate actual route and insert
const routes = createRoutes(rowRoutes)
router.addRoute(routes)
// Generate side menu
this.menus = createMenus(rowRoutes)
// Generate the route cache
this.cacheRoutes = generateCacheRoutes(rowRoutes)
this.isInitAuthRoute = true
}
catch (error) {
// 重置状态并重新抛出错误
this.isInitAuthRoute = false
throw error
}
},
},
})

View File

@ -24,7 +24,7 @@ export const useTabStore = defineStore('tab-store', {
return return
// 如果标签名称已存在则不添加 // 如果标签名称已存在则不添加
if (this.hasExistTab(route.path as string)) if (this.hasExistTab(route.fullPath as string))
return return
// 根据meta.pinTab传递到不同的分组中 // 根据meta.pinTab传递到不同的分组中
@ -33,42 +33,42 @@ export const useTabStore = defineStore('tab-store', {
else else
this.tabs.push(route) this.tabs.push(route)
}, },
async closeTab(path: string) { async closeTab(fullPath: string) {
const tabsLength = this.tabs.length const tabsLength = this.tabs.length
// 如果动态标签大于一个,才会标签跳转 // 如果动态标签大于一个,才会标签跳转
if (this.tabs.length > 1) { if (this.tabs.length > 1) {
// 获取关闭的标签索引 // 获取关闭的标签索引
const index = this.getTabIndex(path) const index = this.getTabIndex(fullPath)
const isLast = index + 1 === tabsLength const isLast = index + 1 === tabsLength
// 如果是关闭的当前页面,路由跳转到原先标签的后一个标签 // 如果是关闭的当前页面,路由跳转到原先标签的后一个标签
if (this.currentTabPath === path && !isLast) { if (this.currentTabPath === fullPath && !isLast) {
// 跳转到后一个标签 // 跳转到后一个标签
router.push(this.tabs[index + 1].path) router.push(this.tabs[index + 1].fullPath)
} }
else if (this.currentTabPath === path && isLast) { else if (this.currentTabPath === fullPath && isLast) {
// 已经是最后一个了,就跳转前一个 // 已经是最后一个了,就跳转前一个
router.push(this.tabs[index - 1].path) router.push(this.tabs[index - 1].fullPath)
} }
} }
// 删除标签 // 删除标签
this.tabs = this.tabs.filter((item) => { this.tabs = this.tabs.filter((item) => {
return item.path !== path return item.fullPath !== fullPath
}) })
// 删除后如果清空了,就跳转到默认首页 // 删除后如果清空了,就跳转到默认首页
if (tabsLength - 1 === 0) if (tabsLength - 1 === 0)
router.push('/') router.push('/')
}, },
closeOtherTabs(path: string) { closeOtherTabs(fullPath: string) {
const index = this.getTabIndex(path) const index = this.getTabIndex(fullPath)
this.tabs = this.tabs.filter((item, i) => i === index) this.tabs = this.tabs.filter((item, i) => i === index)
}, },
closeLeftTabs(path: string) { closeLeftTabs(fullPath: string) {
const index = this.getTabIndex(path) const index = this.getTabIndex(fullPath)
this.tabs = this.tabs.filter((item, i) => i >= index) this.tabs = this.tabs.filter((item, i) => i >= index)
}, },
closeRightTabs(path: string) { closeRightTabs(fullPath: string) {
const index = this.getTabIndex(path) const index = this.getTabIndex(fullPath)
this.tabs = this.tabs.filter((item, i) => i <= index) this.tabs = this.tabs.filter((item, i) => i <= index)
}, },
clearAllTabs() { clearAllTabs() {
@ -80,28 +80,27 @@ export const useTabStore = defineStore('tab-store', {
router.push('/') router.push('/')
}, },
hasExistTab(path: string) { hasExistTab(fullPath: string) {
const _tabs = [...this.tabs, ...this.pinTabs] const _tabs = [...this.tabs, ...this.pinTabs]
return _tabs.some((item) => { return _tabs.some((item) => {
return item.path === path return item.fullPath === fullPath
}) })
}, },
/* 设置当前激活的标签 */ /* 设置当前激活的标签 */
setCurrentTab(path: string) { setCurrentTab(fullPath: string) {
this.currentTabPath = path this.currentTabPath = fullPath
}, },
getTabIndex(path: string) { getTabIndex(fullPath: string) {
return this.tabs.findIndex((item) => { return this.tabs.findIndex((item) => {
return item.path === path return item.fullPath === fullPath
}) })
}, },
modifyTab(fullPath: string, modifyFn: (route: RouteLocationNormalized) => void) {
const index = this.getTabIndex(fullPath)
modifyFn(this.tabs[index])
},
}, },
persist: { persist: {
enabled: true,
strategies: [
{
storage: sessionStorage, storage: sessionStorage,
}, },
],
},
}) })

View File

@ -1,5 +1,6 @@
@import './reset.css'; @import './reset.css';
@import './transition.css'; @import './transition.css';
@import './naive.css';
html, html,
body, body,
@ -13,3 +14,7 @@ body,
.gray-mode { .gray-mode {
filter: grayscale(100%); filter: grayscale(100%);
} }
.drag-handle {
cursor: move;
}

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