1
0
mirror of https://github.com/zclzone/vue-naive-admin.git synced 2025-12-28 20:10:22 +08:00

137 Commits

Author SHA1 Message Date
张传龙
6e26769679 docs: update readme 2022-06-25 14:45:50 +08:00
张传龙
b7ce7912a7 revert: 简化构建步骤,撤销app.config.js功能 2022-06-25 14:45:23 +08:00
张传龙
1fa9d4d472 fix: 修复common文件夹大小写问题 2022-06-25 13:48:54 +08:00
张传龙
fa11b1bc64 fix: 修复common文件夹大小写问题 2022-06-25 13:47:30 +08:00
张传龙
4bf8916fdc style: update settings.json 2022-06-24 15:12:16 +08:00
张传龙
8496c08646 docs: update readme 2022-06-23 19:30:22 +08:00
张传龙
065868a40b fix: 修复高度溢出问题 2022-06-22 20:36:53 +08:00
张传龙
dd4cd871ba style: 样式调整 2022-06-19 19:41:49 +08:00
张传龙
a2b84d35f7 mod: 修改文件夹名大小写 2022-06-19 19:18:12 +08:00
张传龙
e128dfabc7 refactor: Refactor Naive UI AppProvider 2022-06-19 17:11:38 +08:00
张传龙
21c1d6d3aa feat: 添加白屏loading效果 2022-06-19 16:27:44 +08:00
张传龙
3990d4da80 feat: 集成unplugin-auto-import自动引入 2022-06-19 15:22:01 +08:00
张传龙
ac9ccbadf0 mod: 使用unocss重写首页样式 2022-06-19 15:02:58 +08:00
张传龙
8ae4046285 mod: 修改友好提示 2022-06-19 14:50:50 +08:00
张传龙
f88b4f52a1 refactor: 重构图标使用方式,集成自定应图标 2022-06-19 13:35:36 +08:00
张传龙
00ba77c15e chore: 依赖更新 2022-06-18 22:07:50 +08:00
张传龙
39a80926bf perf: 全局样式调整 2022-06-18 22:00:58 +08:00
张传龙
16957a96b7 mod: 使用unocss重写登录页 2022-06-18 19:14:50 +08:00
张传龙
ef33b28492 mod: 细节调整 2022-06-17 23:05:57 +08:00
张传龙
ae43ffb94f refactor: 重构异常处理 2022-06-17 22:44:32 +08:00
张传龙
f0b6ce7d20 chore: update unocss.config.js 2022-06-17 17:48:25 +08:00
张传龙
c3354afa6c docs: update readme 2022-06-15 12:50:49 +08:00
张传龙
08ef914528 chore: update settings.json 2022-06-12 11:37:33 +08:00
张传龙
d86ee26ad6 chore: update naive ui dependencies. 2022-06-11 22:15:15 +08:00
张传龙
c8495f7a5f style: 主题相关调整 2022-06-11 22:14:23 +08:00
张传龙
0636ac4716 perf: 优化多标签滚动 2022-06-11 20:17:30 +08:00
张传龙
67d966e096 refactor: 简化unocss集成 2022-06-11 16:55:36 +08:00
张传龙
b5ac614943 docs: update readme 2022-06-05 18:15:19 +08:00
张传龙
9151b2d297 feat: 菜单自定义排序 2022-06-03 22:42:33 +08:00
张传龙
84f8431134 mod: 删除非必要代码 2022-06-03 19:50:10 +08:00
张传龙
fdc49f6dcc refactor: 重构axios封装 2022-06-03 19:49:38 +08:00
张传龙
d2b88a8300 mod: 调整import顺序 2022-05-25 19:53:57 +08:00
张传龙
ffc042167a fear: 集成Naive UI Notification组件 2022-05-24 15:13:11 +08:00
张传龙
85f9c91d6e mod: 移除非必要代码 2022-05-23 18:05:18 +08:00
张传龙
21391b202f feat: 头部增加github源码网站 2022-05-22 20:50:32 +08:00
张传龙
36ddb23db6 docs: update README 2022-05-21 17:22:35 +08:00
张传龙
f3c391c031 fix: 修改配置路由写法以修复热更新问题 2022-05-20 18:33:13 +08:00
张传龙
df378f784b chore: 依赖更新 2022-05-20 18:31:21 +08:00
张传龙
2154267615 mod: 修改mock数据 2022-05-19 15:52:22 +08:00
张传龙
3203a9a459 chore: update vite.config.js 2022-05-19 15:40:02 +08:00
张传龙
5ce2150706 fix: 修复vite热重启后proxy失效问题 2022-05-19 12:02:01 +08:00
张传龙
5bd380037c mod: 示例代码修改 2022-05-18 20:40:31 +08:00
张传龙
e63e9f5cf2 chore: update settings.json 2022-05-17 11:01:23 +08:00
张传龙
74c244cf37 chore: update extensions 2022-05-17 11:01:12 +08:00
张传龙
5cd85cf72d mod: 修改工具方法 2022-05-16 18:29:32 +08:00
张传龙
96d88a97f1 chore: 依赖更新 2022-05-16 18:27:56 +08:00
张传龙
00c32a950a chore: 依赖更新 2022-05-15 18:51:57 +08:00
张传龙
bfd048d40a mod: 调整示例页面 2022-05-13 15:09:41 +08:00
张传龙
1254a199d7 refactor: 重构sidebar 2022-05-13 14:52:11 +08:00
张传龙
1190d08a87 refactor: 规范文件夹结构 2022-05-12 19:50:21 +08:00
张传龙
958589edd0 refactor: 重构header 2022-05-11 14:54:12 +08:00
张传龙
2338ded165 feat: 集成全屏功能 2022-05-10 17:18:04 +08:00
张传龙
4d6a58bfc8 feat: 集成vueUse 2022-05-10 17:17:49 +08:00
张传龙
f15e21b0a0 feat: 集成菜单栏伸缩功能 2022-05-08 11:59:01 +08:00
张传龙
f88820b727 style: 调整404页面 2022-05-07 23:01:30 +08:00
张传龙
598d256be4 style: lint fix 2022-05-06 22:36:22 +08:00
张传龙
5b51cfb4f1 chore: update extensions 2022-05-06 22:35:33 +08:00
张传龙
45c2e3aebe chore: 简化eslint配置 2022-05-06 22:34:55 +08:00
张传龙
c2249d531f chore: 依赖更新 2022-05-05 21:13:45 +08:00
张传龙
44c6b420d0 chore: 依赖更新 2022-05-04 19:49:55 +08:00
张传龙
117a46a251 chore: update extensions 2022-05-04 18:03:41 +08:00
张传龙
d8569a4eb1 refactor: 简化文件夹结构 2022-05-03 22:37:51 +08:00
张传龙
c268b3c75d mod: 修改table示例的写法 2022-05-03 22:28:51 +08:00
张传龙
21e0d86fcd refactor: app-main 弃用naive ui虚拟滚动条,改成自定义滚动条 2022-05-02 17:39:31 +08:00
张传龙
76bd414941 mod: 调整多标签滑动写法 2022-05-01 18:16:41 +08:00
张传龙
894b87426a fix: 修复标签栏溢出换行问题 2022-04-30 18:39:42 +08:00
张传龙
f9c2362cd8 mod: 规范化调整.vue文件命名 2022-04-29 20:40:29 +08:00
张传龙
d922dcc224 docs: update readme 2022-04-26 19:59:59 +08:00
张传龙
7c8a17bbb2 fix: 修复递归判断单个子路由错误问题 2022-04-25 09:56:23 +08:00
张传龙
321e19a3a5 fix: 修复isHidden路由未正确判断问题 2022-04-25 09:51:02 +08:00
张传龙
bf2d45416f feat: 单个菜单时替换父菜单处理 2022-04-23 22:34:12 +08:00
张传龙
cf1b83d3f1 feat: 集成多标签右键菜单 2022-04-23 19:23:12 +08:00
张传龙
bf63fb5ab7 mod: 修改侧边菜单渲染菜单使用封装的renderIcon方法 2022-04-23 19:10:55 +08:00
张传龙
a6f86ee315 feat: 封装renderIcon方法 2022-04-23 19:09:06 +08:00
张传龙
b2cf78b36d mod: 删除测试代码 2022-04-22 08:58:43 +08:00
张传龙
c9c0c35343 revert: 撤销router-view页面组件多根节点支持 2022-04-21 22:45:10 +08:00
张传龙
3c46d2c159 feat: 集成重新加载页面功能 2022-04-21 22:35:26 +08:00
张传龙
585bf4a4c4 fix: 修复router-view页面组件因transition导致使用多个根节点告警问题 2022-04-20 18:03:23 +08:00
张传龙
2bd85e6e60 feat: 集成rollup打包分析插件 2022-04-19 21:50:19 +08:00
张传龙
967ae1c483 style: 调整layout布局 2022-04-18 22:01:30 +08:00
张传龙
238bceb500 fix: 修复http拦截强制覆盖Authorization问题 2022-04-17 21:16:30 +08:00
张传龙
c2145c0ddb mod: 修改路由name 2022-04-17 16:33:12 +08:00
张传龙
d759c9b9ae docs: update readme 2022-04-16 22:54:03 +08:00
张传龙
3fdba613d3 docs: update readme 2022-04-15 22:19:09 +08:00
张传龙
40d5106c6b release: v0.3.2 2022-04-15 22:16:10 +08:00
张传龙
094a9dcb3b fix: 修复二次登录后标签页会多出登录标签问题 2022-04-14 15:30:34 +08:00
张传龙
db5089d92e chore: update jsconfig.json 2022-04-13 17:24:27 +08:00
张传龙
a41ccad2d0 style: 修改naive ui主题颜色配置 2022-04-12 17:18:21 +08:00
张传龙
8973e39566 feat: 多标签增加sessionStorage缓存 2022-04-11 22:13:17 +08:00
张传龙
8c1191ece2 mod: 文件名模块修改 2022-04-11 22:12:00 +08:00
张传龙
b3aa8147b1 refactor: 重构主题色配置,多标签配置化处理 2022-04-10 23:20:28 +08:00
张传龙
0d240f083a feat: 集成tags多标签功能 2022-04-10 21:41:06 +08:00
张传龙
c180cf54a8 Merge branch 'main' of https://github.com/zclzone/vue-naive-admin 2022-04-10 14:08:03 +08:00
张传龙
2541706ac3 docs: update readme 2022-04-10 14:06:06 +08:00
Ronnie Zhang
44b935e8f6 Create LICENSE 2022-04-10 12:57:19 +08:00
张传龙
ea1ce0601a ci: 修改 github actions 配置文件 2022-04-10 00:03:28 +08:00
张传龙
2989ecf126 ci: 修改 github actions 配置文件 2022-04-09 23:43:38 +08:00
张传龙
ce94bf38d1 docs: update readme 2022-04-09 23:38:43 +08:00
张传龙
13bc185926 ci: 修改 github actions 配置文件 2022-04-09 23:36:19 +08:00
张传龙
51cfd3e2eb ci: 修改 github actions 配置文件 2022-04-09 23:29:54 +08:00
张传龙
c22cb3b35c ci: 修改 github actions 配置文件 2022-04-09 23:14:02 +08:00
张传龙
621a2304e7 ci: 修改 github actions 配置文件 2022-04-09 23:03:09 +08:00
张传龙
729337cdc5 ci: 修改 github actions 配置文件 2022-04-09 22:59:05 +08:00
张传龙
4ef58b612f ci: 修改 github actions 配置文件 2022-04-09 22:43:58 +08:00
张传龙
ba5d32244f ci: 修改 github actions 配置文件 2022-04-09 22:42:25 +08:00
张传龙
efc2a194a3 ci: 修改 github actions 配置文件 2022-04-09 22:37:44 +08:00
张传龙
6160c2e664 chore: 修改github actions配置文件 2022-04-09 22:33:13 +08:00
张传龙
fbd1e9a38a ci: 集成github actions自动发布github pages 2022-04-09 22:30:21 +08:00
张传龙
17928cbc57 chore: 集成github pages发布环境 2022-04-09 22:29:11 +08:00
张传龙
e7fc403c77 style: 删除无用注释代码 2022-04-09 19:32:15 +08:00
张传龙
3f7ed95fdb style: 增加vscode插件推荐列表 2022-04-09 19:05:58 +08:00
张传龙
7478f193f9 Merge branch 'main' of https://github.com/zclzone/vue-naive-admin 2022-04-08 19:39:35 +08:00
张传龙
ba49d94bf4 docs: 完善插件使用注释 2022-04-08 19:35:22 +08:00
Ronnie Zhang
ea9851ccd3 Merge pull request #7 from liulinboyi/fix/dialog
fix: $dialog对话框可以异步
2022-04-08 18:34:23 +08:00
张传龙
ec55f33655 feat: 配合unplugin-icons集成iconify图标解决方案 2022-04-08 17:21:48 +08:00
liulinboyi
7a85c714cb fix: $dialog对话框可以异步 2022-04-08 16:32:33 +08:00
张传龙
7b90d7f8de style: 修改html代码风格 2022-04-08 11:25:11 +08:00
张传龙
16f580c96d mod: 修改外链中文档地址 2022-04-05 17:40:32 +08:00
张传龙
f1329a46e4 docs: update readme 2022-04-05 17:35:05 +08:00
张传龙
ef6df57dc5 style: 修改404图片 2022-04-05 11:33:35 +08:00
张传龙
95e5cd7134 style: 修改favicon 2022-04-05 11:33:18 +08:00
张传龙
9db7aa50a1 fix: 修复更新Naive UI版本后菜单高亮样式失效问题 2022-04-05 00:37:30 +08:00
张传龙
a9997984d5 refactor: 重构登录页UI 2022-04-05 00:36:24 +08:00
张传龙
acb47a17b4 refactor: 规范化调整.vue文件结构及命名 2022-04-03 19:45:39 +08:00
张传龙
9c5f4eaa3d chore: 依赖更新 2022-04-02 09:39:16 +08:00
张传龙
361fb52345 mod: 修改配置,打包默认不生成CNAME文件 2022-04-01 17:59:04 +08:00
张传龙
5993e8d7d0 refactor: 全局规范化调整文件夹和文件命名(代码无改动) 2022-04-01 17:41:07 +08:00
张传龙
8648f16ed8 mod: remove router.isReady 2022-04-01 16:39:26 +08:00
张传龙
33aaadba60 fix: 修复挂载路由时使用$loadingBar出错问题 2022-03-29 09:28:36 +08:00
张传龙
437d87f19e mod: 调整setupApp相关写法 2022-03-28 18:31:32 +08:00
张传龙
dfcc8c2158 mod: 修改失效图片链接 2022-03-26 17:50:29 +08:00
张传龙
51a583fc1e feat: 添加pageTitle路由守卫,支持动态修改页面title 2022-03-24 20:47:05 +08:00
张传龙
396428104a mod: 修改并完善首页 2022-03-20 18:59:20 +08:00
张传龙
ff2c25ff75 style: 调整测试页面和组件示例页面样式 2022-03-20 17:33:07 +08:00
张传龙
4f78bcb77c style: 调整app-main样式 2022-03-20 17:00:47 +08:00
张传龙
f62f59720d style: 调整header样式 2022-03-20 14:45:36 +08:00
张传龙
f296490569 docs: update readme 2022-03-19 19:13:07 +08:00
113 changed files with 3239 additions and 2231 deletions

3
.env
View File

@@ -1,6 +1,3 @@
VITE_APP_TITLE = 'Vue Naive Admin'
VITE_PORT = 3100
# 打包时自动生成CNAME文件用于配置github pages自定义域名如不需要可注释或者直接删除
VITE_APP_GLOB_CNAME = 'template.qszone.com'

View File

@@ -8,7 +8,7 @@ VITE_APP_USE_MOCK = true
VITE_PROXY = [["/api","http://localhost:8080"],["/api-test","localhost:8080"]]
# base api
VITE_APP_GLOB_BASE_API = '/api'
VITE_APP_BASE_API = '/api'
# test base api
VITE_APP_GLOB_BASE_API_TEST = '/api-test'
VITE_APP_BASE_API_TEST = '/api-test'

16
.env.github Normal file
View File

@@ -0,0 +1,16 @@
# 自定义域名CNAME
# VITE_APP_GLOB_CNAME = 'template.qszone.com'
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH = '/vue-naive-admin/'
VITE_APP_USE_HASH = true
# 是否启用MOCK
VITE_APP_USE_MOCK = true
# base api
VITE_APP_BASE_API = '/api'
# test base api
VITE_APP_BASE_API_TEST = '/api-test'

View File

@@ -5,7 +5,7 @@ VITE_PUBLIC_PATH = '/'
VITE_APP_USE_MOCK = true
# base api
VITE_APP_GLOB_BASE_API = '/api'
VITE_APP_BASE_API = '/api'
# test base api
VITE_APP_GLOB_BASE_API_TEST = '/api-test'
VITE_APP_BASE_API_TEST = '/api-test'

View File

@@ -1,11 +0,0 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH = '/'
# 是否启用MOCK
VITE_APP_USE_MOCK = false
# base api
VITE_APP_GLOB_BASE_API = 'http://localhost:8080/api'
# test base api
VITE_APP_GLOB_BASE_API_TEST = 'http://localhost:8080/api-test'

View File

@@ -4,7 +4,7 @@ VITE_PUBLIC_PATH = '/'
VITE_APP_USE_MOCK = true
# base api
VITE_APP_GLOB_BASE_API = '/api'
VITE_APP_BASE_API = '/api'
# test base api
VITE_APP_GLOB_BASE_API_TEST = '/api-test'
VITE_APP_BASE_API_TEST = '/api-test'

View File

@@ -1,3 +1,4 @@
node_modules
dist
public
public
package.json

View File

@@ -1,19 +1,8 @@
// * https://zhuanlan.zhihu.com/p/388703150
module.exports = {
root: true,
env: {
browser: true, // browser global variables
node: true,
es2021: true, // adds all ECMAScript 2021 globals and automatically sets the ecmaVersion parser option to 12.
},
parserOptions: {
ecmaVersion: 2020,
},
parser: 'vue-eslint-parser',
extends: ['plugin:vue/vue3-recommended', 'plugin:prettier/recommended'],
plugins: ['prettier'],
rules: {
'prettier/prettier': 'error',
'prettier/prettier': 'warn',
'vue/valid-template-root': 'off',
'vue/no-multiple-template-root': 'off',
'vue/multi-word-component-names': [

38
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: use Node.js 16
uses: actions/setup-node@v2.1.2
with:
node-version: '16.x'
- name: use pnpm 6.32.2
uses: pnpm/action-setup@v2.2.1
with:
version: 6.32.2
- name: Build
run: |
pnpm install
pnpm run build:github
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
publish_dir: ./dist
github_token: ${{ secrets.ACTIONS_DEPLOY_KEY }}
user_name: ${{ secrets.USER_NAME }}
user_email: ${{ secrets.USER_EMAIL }}
force_orphan: true
commit_message: deploy gh-pages

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
*.local
stats.html

View File

@@ -1,11 +1,9 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"johnsoncodehk.volar",
"hollowtree.vue-snippets",
"esbenp.prettier-vscode",
"vue.volar",
"antfu.iconify",
"mikestead.dotenv",
"wayou.vscode-todo-highlight",
"aaron-bond.better-comments"
"sdras.vue-vscode-snippets",
"cipchk.cssrem",
]
}

20
.vscode/settings.json vendored
View File

@@ -3,24 +3,10 @@
"path-intellisense.mappings": {
"@/": "${workspaceRoot}/src"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": false,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": ["javascript", "javascriptreact", "typescript"]
"cssrem.rootFontSize": 4, // 适配unocss1rem = 4px ==> 0.25rem = 1px
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Ronnie Zhang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,37 +1,53 @@
# VUE NAIVE ADMIN
<p align="center">
<a href="https://github.com/zclzone/vue-naive-admin">
<img alt="Vue Naive Admin Logo" width="200" src="https://assets.qszone.com/images/logo_qs.svg">
</a>
</p>
<p align="center">
<a href="https://github.com/zclzone/vue-naive-admin/actions"><img allt="checks" src="https://badgen.net/github/checks/zclzone/vue-naive-admin"/></a>
<a href="https://github.com/zclzone/vue-naive-admin"><img allt="stars" src="https://badgen.net/github/stars/zclzone/vue-naive-admin"/></a>
<a href="https://github.com/zclzone/vue-naive-admin"><img allt="forks" src="https://badgen.net/github/forks/zclzone/vue-naive-admin"/></a>
<a href="./LICENSE"><img allt="MIT License" src="https://badgen.net/github/license/zclzone/vue-naive-admin"/></a>
</p>
## 简介
Vue Naive Admin一个基于 Vue3.0、Vite、Naive UI 的轻量级后台管理模板,没有集成 TypeScript没有集成国际化没有集成复杂的主题配置上手成本非常低对新手极其友好。不过麻雀虽小五脏俱全权限、Mock、菜单、axios 封装、pinia、项目配置、样式配置、环境配置以及一些经常用的基础组件封装等等这些该有的都有参考多个 vue3 后台管理模板后以最简洁优雅的方式实现,非常适用于中小型项目或者个人项目。
### 简介
## 为什么要开发这个模板
[Vue Naive Admin](https://github.com/zclzone/vue-naive-admin),一个基于 Vue3.0、Vite、Naive UI 的后台管理模板相较于其他比较流行的后台管理模板此项目相对简洁、轻量学习成本非常低对新手极其友好。不过麻雀虽小五脏俱全权限、Mock、菜单、axios 封装、pinia、项目配置、样式配置、环境配置以及一些经常用的基础组件封装等等这些该有的都有非常适用于中小型项目或者个人项目也可此模板进行二次封装改造用于大型项目。
1. Vue3 和 Vite 已经趋于成熟,学习 vite 和 vue3 非常有必要,通过开发模板进行学习是一个很好的方式,事实也证明我确实从中获益良多
2. 目前主流的 Vue3+Vite 后台管理模板都相对复杂,甚至感觉有点花里胡哨(没有贬低的意思,大部分的架构设计都很优秀,只是觉得集成了太多不实用的东西)
3. 自己搭的模板开发起来才最顺手。本人很反感拿别人的模板直接上手开发,如果非要拿别人的模板开发也会尽量先吃透再用,不吃透就没有代码的掌控感和安全感
### 为什么要开发这个模板
## 功能
- Vue3 和 Vite 已经趋于成熟,学习 vite 和 vue3 非常有必要,通过开发模板进行学习是一个很好的方式,事实也证明我确实从中获益良多
- 目前主流的 Vue3+Vite 后台管理模板都相对复杂,甚至感觉有点花里胡哨(没有贬低的意思,大部分的架构设计都很优秀,只是觉得集成了太多不实用的东西)
- 🍒 集成 Naive UI尤大推荐的 UI 组件库很香https://www.naiveui.com
- 🍑 集成登陆、注销及权限验证(暂只支持角色页面权限,后续考虑添加按钮权限)
- 🍐 集成多环境配置dev、测试、预发布和生产
- 🍎 集成 eslint + prettier代码约束和格式化统一
### 功能
- 🍒 集成 Naive UI尤大推荐的 UI 组件库,[https://www.naiveui.com](https://www.naiveui.com)
- 🍑 集成登陆、注销及权限验证
- 🍐 集成多环境配置dev、测试、生产和github pages环境
- 🍎 集成 Eslint + Prettier代码约束和格式化统一
- 🍉 集成 Mock 接口服务dev 环境和发布环境都支持,可动态配置是否启用 mock 服务,不启用时不会加载 mock 包,减少打包体积
- 🍇 集成 unocssantfu 大神开源的原子化 css 解决方案,非常轻量,目前我是自己写 scss 样式搭配着 unocss 使用的,很香
- 🍍 集成 piniaVuex 的替代方案,轻量、简单、易用,很香
- 🍏 集成 axios支持多 axios 实例,支持线上环境免重新打包修改 baseURL
- 🍇 集成 unocssantfu 大神开源的原子化 css 解决方案,非常轻量,目前我是自己写 scss 样式搭配着 unocss 使用的
- 🍍 集成 PiniaVuex 的替代方案,轻量、简单、易用尤大已表示不会有Vuex5或者说pinia就是Vuex5
- 📦 集成 Vite 自动导入插件unplugin-vue-components解放双手开发效率直接起飞
- 🤹 集成 unplugin-icons插件优雅使用iconify图标
- 🍏 二次封装 Axios支持多 axios 实例
- 🍌 二次封装全局 Dialog、Message、LoadingBar 组件
- 🍋 二次封装 localStorage 和 sessionStorage支持设置过期时间
## 预览
### 预览
[template.qszone.com](https://template.qszone.com)
## 文档
[github pages](https://zclzone.github.io/vue-naive-admin)
### 文档
[Vue Naive Admin Docs](https://zclzone.github.io/vue-naive-admin-docs)
[羽雀文档Vue Naive Admin](https://www.yuque.com/qszone/vue-naive-admin)
## 构建步骤
### 构建
```shell
# 推荐配置git autocrlf 为 false本项目规范使用lf换行符此配置是为防止git自动将源文件转换为crlf
@@ -51,20 +67,20 @@ pnpm i # 或者 npm i
npm run dev
```
## 发布
### 发布
```shell
# 构建测试环境
npm run build:test
# 构建预发布环境
npm run build:staging
# 构建github pages环境
npm run build:github
# 构建生产环境
npm run build
```
## 其他指令
### 其他指令
```shell
# eslint代码格式检查
@@ -76,3 +92,31 @@ npm run lint:fix
# 预览发布包效果(需先执行构建指令)
npm run preview
```
### 规范
#### git commit 规范
- `feat` 增加新功能
- `fix` 修复问题/BUG
- `style` 代码风格相关无影响运行结果的
- `perf` 优化/性能提升
- `refactor` 重构
- `revert` 撤销修改
- `test` 测试相关
- `docs` 文档/注释
- `chore` 依赖更新/脚手架配置修改等
- `workflow` 工作流改进
- `ci` 持续集成
- `types` 类型定义文件更改
- `wip` 开发中
- `mod` 不确定分类的修改
- `release` 发布
<p align="center">
<img src="https://assets.qszone.com/image/Snipaste_2022-06-23_19-26-26.png" />
</p>

View File

@@ -1,3 +1 @@
export const GLOB_CONFIG_FILE_NAME = 'app.config.js'
export const GLOB_CONFIG_NAME = '__APP__GLOB__CONF__'
export const OUTPUT_DIR = 'dist'

15
build/plugin/html.js Normal file
View File

@@ -0,0 +1,15 @@
import { createHtmlPlugin } from 'vite-plugin-html'
export function configHtmlPlugin(viteEnv, isBuild) {
const { VITE_APP_TITLE, VITE_PUBLIC_PATH } = viteEnv
const htmlPlugin = createHtmlPlugin({
minify: isBuild,
inject: {
data: {
title: VITE_APP_TITLE,
},
},
})
return htmlPlugin
}

40
build/plugin/index.js Normal file
View File

@@ -0,0 +1,40 @@
import vue from '@vitejs/plugin-vue'
/**
* * 扩展setup插件支持在script标签中使用name属性
* usage: <script setup name="MyComp"></script>
*/
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
/**
* * unocss插件原子css
* https://github.com/antfu/unocss
*/
import Unocss from 'unocss/vite'
// rollup打包分析插件
import visualizer from 'rollup-plugin-visualizer'
import { configHtmlPlugin } from './html'
import { configMockPlugin } from './mock'
import unplugin from './unplugin'
export function createVitePlugins(viteEnv, isBuild) {
const plugins = [vue(), VueSetupExtend(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
if (viteEnv?.VITE_APP_USE_MOCK) {
plugins.push(configMockPlugin(isBuild))
}
if (isBuild) {
plugins.push(
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
})
)
}
return plugins
}

View File

@@ -7,7 +7,7 @@ export function configMockPlugin(isBuild) {
localEnabled: !isBuild,
prodEnabled: isBuild,
injectCode: `
import { setupProdMockServer } from '../mock/_createProdServer';
import { setupProdMockServer } from '../mock/_create-prod-server';
setupProdMockServer();
`,
})

32
build/plugin/unplugin.js Normal file
View File

@@ -0,0 +1,32 @@
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
/**
* * unplugin-icons插件自动引入iconify图标
* usage: https://github.com/antfu/unplugin-icons
* 图标库: https://icones.js.org/
*/
import Icons from 'unplugin-icons/vite'
import { getRootPath } from '../utils'
const customIconPath = getRootPath('src', 'assets/icons')
export default [
AutoImport({
imports: ['vue', 'vue-router'],
}),
Icons({
compiler: 'vue3',
customCollections: {
custom: FileSystemIconLoader(customIconPath),
},
scale: 1,
defaultClass: 'inline-block',
}),
Components({
resolvers: [NaiveUiResolver(), IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' })],
}),
]

View File

@@ -4,10 +4,10 @@ import { OUTPUT_DIR } from '../constant'
import { getEnvConfig, getRootPath } from '../utils'
export function runBuildCNAME() {
const { VITE_APP_GLOB_CNAME } = getEnvConfig()
if (!VITE_APP_GLOB_CNAME) return
const { VITE_APP_CNAME } = getEnvConfig()
if (!VITE_APP_CNAME) return
try {
writeFileSync(getRootPath(`${OUTPUT_DIR}/CNAME`), VITE_APP_GLOB_CNAME)
writeFileSync(getRootPath(`${OUTPUT_DIR}/CNAME`), VITE_APP_CNAME)
} catch (error) {
console.log(chalk.red('CNAME file failed to package:\n' + error))
}

View File

@@ -1,29 +0,0 @@
import { GLOB_CONFIG_FILE_NAME, GLOB_CONFIG_NAME, OUTPUT_DIR } from '../constant'
import fs, { writeFileSync } from 'fs-extra'
import chalk from 'chalk'
import { getEnvConfig, getRootPath } from '../utils'
function createConfig(option) {
const { config, configName, configFileName } = option
try {
const windowConf = `window.${configName}`
const configStr = `${windowConf}=${JSON.stringify(config)};
Object.freeze(${windowConf});
Object.defineProperty(window, "${configName}", {
configurable: false,
writable: false,
});
`.replace(/\s/g, '')
fs.mkdirp(getRootPath(OUTPUT_DIR))
writeFileSync(getRootPath(`${OUTPUT_DIR}/${configFileName}`), configStr)
} catch (error) {
console.log(chalk.red('configuration file configuration file failed to package:\n' + error))
}
}
export function runBuildConfig() {
const config = getEnvConfig()
const configName = GLOB_CONFIG_NAME
const configFileName = GLOB_CONFIG_FILE_NAME
createConfig({ config, configName, configFileName })
}

View File

@@ -1,10 +1,8 @@
import chalk from 'chalk'
import { runBuildConfig } from './build-config'
import { runBuildCNAME } from './build-cname'
export const runBuild = async () => {
try {
runBuildConfig()
runBuildCNAME()
console.log(`${chalk.cyan('build successfully!')}`)
} catch (error) {

View File

@@ -2,6 +2,8 @@ import fs from 'fs'
import path from 'path'
import dotenv from 'dotenv'
const httpsReg = /^https:\/\//
export function wrapperEnv(envOptions) {
if (!envOptions) return {}
const ret = {}
@@ -14,7 +16,7 @@ export function wrapperEnv(envOptions) {
if (['VITE_PORT'].includes(key)) {
val = +val
}
if (key === 'VITE_PROXY' && val) {
if (key === 'VITE_PROXY' && val && typeof val === 'string') {
try {
val = JSON.parse(val.replace(/'/g, '"'))
} catch (error) {
@@ -22,15 +24,33 @@ export function wrapperEnv(envOptions) {
}
}
ret[key] = val
if (typeof key === 'string') {
if (typeof val === 'string') {
process.env[key] = val
} else if (typeof key === 'object') {
} else if (typeof val === 'object') {
process.env[key] = JSON.stringify(val)
}
}
return ret
}
export function createProxy(list = []) {
const ret = {}
for (const [prefix, target] of list) {
const isHttps = httpsReg.test(target)
// https://github.com/http-party/node-http-proxy#options
ret[prefix] = {
target: target,
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
// https is require secure=false
...(isHttps ? { secure: false } : {}),
}
}
return ret
}
/**
* 获取当前环境下生效的配置文件名
*/

View File

@@ -1,32 +0,0 @@
import html from 'vite-plugin-html'
import { version } from '../../../package.json'
import { GLOB_CONFIG_FILE_NAME } from '../../constant'
export function configHtmlPlugin(viteEnv, isBuild) {
const { VITE_APP_TITLE, VITE_PUBLIC_PATH } = viteEnv
const path = VITE_PUBLIC_PATH.endsWith('/') ? VITE_PUBLIC_PATH : `${VITE_PUBLIC_PATH}/`
const getAppConfigSrc = () => {
return `${path}${GLOB_CONFIG_FILE_NAME}?v=${version}-${new Date().getTime()}`
}
const htmlPlugin = html({
minify: isBuild,
inject: {
data: {
title: VITE_APP_TITLE,
},
tags: isBuild
? [
{
tag: 'script',
attrs: {
src: getAppConfigSrc(),
},
},
]
: [],
},
})
return htmlPlugin
}

View File

@@ -1,26 +0,0 @@
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import { unocss } from './unocss'
import { configHtmlPlugin } from './html'
import { configMockPlugin } from './mock'
export function createVitePlugins(viteEnv, isBuild) {
const plugins = [
vue(),
Components({
resolvers: [NaiveUiResolver()],
}),
VueSetupExtend(),
unocss(),
configHtmlPlugin(viteEnv, isBuild),
]
viteEnv?.VITE_APP_USE_MOCK && plugins.push(configMockPlugin(isBuild))
return plugins
}

View File

@@ -1,9 +0,0 @@
import Unocss from 'unocss/vite'
import { presetUno, presetAttributify, presetIcons } from 'unocss'
// https://github.com/antfu/unocss
export function unocss() {
return Unocss({
presets: [presetUno(), presetAttributify(), presetIcons()],
})
}

View File

@@ -1,18 +0,0 @@
const httpsRE = /^https:\/\//
export function createProxy(list = []) {
const ret = {}
for (const [prefix, target] of list) {
const isHttps = httpsRE.test(target)
// https://github.com/http-party/node-http-proxy#options
ret[prefix] = {
target: target,
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
// https is require secure=false
...(isHttps ? { secure: false } : {}),
}
}
return ret
}

View File

@@ -1,22 +1,37 @@
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-control" content="no-cache" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/resource/loading.css" />
<head>
<meta charset="UTF-8" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Cache-control" content="no-cache" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>
<%= title %>
</title>
</head>
<title><%= title %></title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
<body>
<div id="app">
<!-- 白屏时的loading效果 -->
<div class="loading-container">
<div id="loadingLogo" class="loading-svg"></div>
<div class="loading-spin__container">
<div class="loading-spin">
<div class="left-0 top-0 loading-spin-item"></div>
<div class="left-0 bottom-0 loading-spin-item loading-delay-500"></div>
<div class="right-0 top-0 loading-spin-item loading-delay-1000"></div>
<div class="right-0 bottom-0 loading-spin-item loading-delay-1500"></div>
</div>
</div>
<div class="loading-title"><%= title %></div>
</div>
<script src="/resource/loading.js"></script>
</html>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -3,7 +3,8 @@
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"jsx": "preserve"
},
"exclude": ["node_modules", "dist"]
}

File diff suppressed because one or more lines are too long

View File

@@ -4,21 +4,21 @@ const users = {
admin: {
id: 1,
name: '大脸怪(admin)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
avatar: 'https://assets.qszone.com/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['admin'],
},
editor: {
id: 2,
name: '大脸怪(editor)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
avatar: 'https://assets.qszone.com/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['editor'],
},
guest: {
id: 3,
name: '访客(guest)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
avatar: 'https://assets.qszone.com/images/avatar.jpg',
role: [],
},
}

View File

@@ -1,48 +1,49 @@
{
"name": "vue-naive-admin",
"version": "0.0.1",
"version": "0.4.0",
"scripts": {
"dev": "vite",
"lint": "eslint --ext .js,.vue .",
"lint:fix": "eslint --fix --ext .js,.vue .",
"build": "vite build && esno ./build/script",
"build:test": "vite build --mode test && esno ./build/script",
"build:staging": "vite build --mode staging && esno ./build/script",
"build": "vite build",
"build:test": "vite build --mode test",
"build:github": "vite build --mode github && esno ./build/script",
"preview": "vite preview"
},
"dependencies": {
"@vicons/fa": "^0.11.0",
"@vueuse/core": "^8.4.2",
"axios": "^0.21.4",
"dayjs": "^1.10.7",
"lodash-es": "^4.17.21",
"md-editor-v3": "^1.10.2",
"dayjs": "^1.11.0",
"md-editor-v3": "^1.11.4",
"mockjs": "^1.1.0",
"pinia": "^2.0.11",
"vue": "^3.2.30",
"vue-router": "^4.0.12"
"pinia": "^2.0.13",
"vue": "^3.2.31",
"vue-router": "^4.0.15"
},
"devDependencies": {
"@unocss/preset-attributify": "^0.16.4",
"@unocss/preset-icons": "^0.16.4",
"@unocss/preset-uno": "^0.16.4",
"@iconify/json": "^2.1.63",
"@iconify/vue": "^3.2.1",
"@vitejs/plugin-vue": "^1.10.2",
"@vue/compiler-sfc": "^3.2.30",
"chalk": "^5.0.0",
"@vue/compiler-sfc": "^3.2.31",
"chalk": "^5.0.1",
"dotenv": "^10.0.0",
"eslint": "^8.6.0",
"eslint-config-prettier": "^8.3.0",
"eslint": "^8.12.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.2.0",
"eslint-plugin-vue": "^8.5.0",
"esno": "^0.13.0",
"fs-extra": "^10.0.0",
"naive-ui": "^2.25.2",
"prettier": "^2.5.1",
"sass": "^1.38.1",
"unocss": "^0.16.4",
"unplugin-vue-components": "^0.17.18",
"vite": "^2.8.0",
"vite-plugin-html": "^2.1.1",
"fs-extra": "^10.0.1",
"naive-ui": "^2.30.3",
"prettier": "^2.6.1",
"rollup-plugin-visualizer": "^5.6.0",
"sass": "^1.49.10",
"unocss": "^0.38.2",
"unplugin-auto-import": "^0.8.8",
"unplugin-icons": "^0.14.1",
"unplugin-vue-components": "^0.17.21",
"vite": "^2.9.9",
"vite-plugin-html": "^3.2.0",
"vite-plugin-mock": "^2.9.6",
"vite-plugin-vue-setup-extend": "^0.3.0"
}
}
}

1700
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512" data-v-fba6e5d0=""><path d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-92.2 312.9c-63.4 0-85.4-28.6-97.1-64.1c-16.3-51-21.5-84.3-63-84.3c-22.4 0-45.1 16.1-45.1 61.2c0 35.2 18 57.2 43.3 57.2c28.6 0 47.6-21.3 47.6-21.3l11.7 31.9s-19.8 19.4-61.2 19.4c-51.3 0-79.9-30.1-79.9-85.8c0-57.9 28.6-92 82.5-92c73.5 0 80.8 41.4 100.8 101.9c8.8 26.8 24.2 46.2 61.2 46.2c24.9 0 38.1-5.5 38.1-19.1c0-19.9-21.8-22-49.9-28.6c-30.4-7.3-42.5-23.1-42.5-48c0-40 32.3-52.4 65.2-52.4c37.4 0 60.1 13.6 63 46.6l-36.7 4.4c-1.5-15.8-11-22.4-28.6-22.4c-16.1 0-26 7.3-26 19.8c0 11 4.8 17.6 20.9 21.3c32.7 7.1 71.8 12 71.8 57.5c.1 36.7-30.7 50.6-76.1 50.6z" fill="#316c72"></path></svg>

After

Width:  |  Height:  |  Size: 825 B

View File

@@ -0,0 +1,91 @@
.loading-container {
position: fixed;
left: 0;
top: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.loading-svg {
width: 128px;
height: 128px;
color: var(--primaryColor);
}
.loading-spin__container {
width: 56px;
height: 56px;
margin: 36px 0;
}
.loading-spin {
position: relative;
height: 100%;
animation: loadingSpin 1s linear infinite;
}
.left-0 {
left: 0;
}
.right-0 {
right: 0;
}
.top-0 {
top: 0;
}
.bottom-0 {
bottom: 0;
}
.loading-spin-item {
position: absolute;
height: 16px;
width: 16px;
background-color: var(--primaryColor);
border-radius: 8px;
-webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes loadingSpin {
from {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loadingPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.loading-delay-500 {
-webkit-animation-delay: 500ms;
animation-delay: 500ms;
}
.loading-delay-1000 {
-webkit-animation-delay: 1000ms;
animation-delay: 1000ms;
}
.loading-delay-1500 {
-webkit-animation-delay: 1500ms;
animation-delay: 1500ms;
}
.loading-title {
font-size: 28px;
font-weight: 500;
color: #6a6a6a;
}

View File

@@ -0,0 +1,25 @@
/**
* 初始化加载效果的svg格式logo
* @param {string} id - 元素id
*/
function initSvgLogo(id) {
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512" data-v-fba6e5d0=""><path d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-92.2 312.9c-63.4 0-85.4-28.6-97.1-64.1c-16.3-51-21.5-84.3-63-84.3c-22.4 0-45.1 16.1-45.1 61.2c0 35.2 18 57.2 43.3 57.2c28.6 0 47.6-21.3 47.6-21.3l11.7 31.9s-19.8 19.4-61.2 19.4c-51.3 0-79.9-30.1-79.9-85.8c0-57.9 28.6-92 82.5-92c73.5 0 80.8 41.4 100.8 101.9c8.8 26.8 24.2 46.2 61.2 46.2c24.9 0 38.1-5.5 38.1-19.1c0-19.9-21.8-22-49.9-28.6c-30.4-7.3-42.5-23.1-42.5-48c0-40 32.3-52.4 65.2-52.4c37.4 0 60.1 13.6 63 46.6l-36.7 4.4c-1.5-15.8-11-22.4-28.6-22.4c-16.1 0-26 7.3-26 19.8c0 11 4.8 17.6 20.9 21.3c32.7 7.1 71.8 12 71.8 57.5c.1 36.7-30.7 50.6-76.1 50.6z" style="fill:currentColor"></path></svg>`;
const appEl = document.querySelector(id);
const div = document.createElement('div');
div.innerHTML = svgStr;
if (appEl) {
appEl.appendChild(div);
}
}
function addThemeColorCssVars() {
const key = '__THEME_COLOR__'
const defaultColor = '#316c72';
const themeColor = window.localStorage.getItem(key) || defaultColor;
const cssVars = `--primaryColor: ${themeColor}`;
document.documentElement.style.cssText = cssVars;
}
addThemeColorCssVars();
initSvgLogo('#loadingLogo');

View File

@@ -1,15 +1,15 @@
<script setup>
import AppProvider from '@/components/AppProvider/index.vue'
</script>
<template>
<app-provider>
<AppProvider>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</app-provider>
</AppProvider>
</template>
<script setup>
import AppProvider from '@/components/common/AppProvider.vue'
</script>
<style lang="scss">
#app {
height: 100%;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512" data-v-fba6e5d0=""><path d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-92.2 312.9c-63.4 0-85.4-28.6-97.1-64.1c-16.3-51-21.5-84.3-63-84.3c-22.4 0-45.1 16.1-45.1 61.2c0 35.2 18 57.2 43.3 57.2c28.6 0 47.6-21.3 47.6-21.3l11.7 31.9s-19.8 19.4-61.2 19.4c-51.3 0-79.9-30.1-79.9-85.8c0-57.9 28.6-92 82.5-92c73.5 0 80.8 41.4 100.8 101.9c8.8 26.8 24.2 46.2 61.2 46.2c24.9 0 38.1-5.5 38.1-19.1c0-19.9-21.8-22-49.9-28.6c-30.4-7.3-42.5-23.1-42.5-48c0-40 32.3-52.4 65.2-52.4c37.4 0 60.1 13.6 63 46.6l-36.7 4.4c-1.5-15.8-11-22.4-28.6-22.4c-16.1 0-26 7.3-26 19.8c0 11 4.8 17.6 20.9 21.3c32.7 7.1 71.8 12 71.8 57.5c.1 36.7-30.7 50.6-76.1 50.6z" fill="#316c72"></path></svg>

After

Width:  |  Height:  |  Size: 825 B

BIN
src/assets/images/404.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -1,52 +0,0 @@
<script setup>
import { isNullOrUndef } from '@/utils/is'
import { useDialog } from 'naive-ui'
const NDialog = useDialog()
class Dialog {
success(title, option) {
this.showDialog('success', { title, ...option })
}
warning(title, option) {
this.showDialog('warning', { title, ...option })
}
error(title, option) {
this.showDialog('error', { title, ...option })
}
showDialog(type = 'success', option) {
if (isNullOrUndef(option.title)) {
// ! 没有title的情况
option.showIcon = false
}
NDialog[type]({
positiveText: 'OK',
closable: false,
...option,
})
}
confirm(option = {}) {
this.showDialog(option.type || 'error', {
positiveText: '确定',
negativeText: '取消',
onPositiveClick: option.confirm,
onNegativeClick: option.cancel,
onMaskClick: option.cancel,
...option,
})
}
}
window['$dialog'] = new Dialog()
Object.freeze(window.$dialog)
Object.defineProperty(window, '$dialog', {
configurable: false,
writable: false,
})
</script>
<template></template>

View File

@@ -1,10 +0,0 @@
<script setup>
import { useLoadingBar } from 'naive-ui'
window['$loadingBar'] = useLoadingBar()
Object.defineProperty(window, '$loadingBar', {
configurable: false,
writable: false,
})
</script>
<template></template>

View File

@@ -1,72 +0,0 @@
<script setup>
import { useMessage } from 'naive-ui'
const NMessage = useMessage()
let loadingMessage = null
class Message {
/**
* 规则:
* * loading message只显示一个新的message会替换正在显示的loading message
* * loading message不会自动清除除非被替换成非loading message非loading message默认2秒后自动清除
*/
removeMessage(message, duration = 2000) {
setTimeout(() => {
if (message) {
message.destroy()
message = null
}
}, duration)
}
showMessage(type, content, option = {}) {
if (loadingMessage && loadingMessage.type === 'loading') {
// 如果存在则替换正在显示的loading message
loadingMessage.type = type
loadingMessage.content = content
if (type !== 'loading') {
// 非loading message需设置自动清除
this.removeMessage(loadingMessage, option.duration)
}
} else {
// 不存在正在显示的loading则新建一个message,如果新建的message是loading message则将message赋值存储下来
let message = NMessage[type](content, option)
if (type === 'loading') {
loadingMessage = message
}
}
}
loading(content) {
this.showMessage('loading', content, { duration: 0 })
}
success(content, option = {}) {
this.showMessage('success', content, option)
}
error(content, option = {}) {
this.showMessage('error', content, option)
}
info(content, option = {}) {
this.showMessage('info', content, option)
}
warning(content, option = {}) {
this.showMessage('warning', content, option)
}
}
window['$message'] = new Message()
Object.defineProperty(window, '$message', {
configurable: false,
writable: false,
})
</script>
<template></template>

View File

@@ -1,23 +0,0 @@
<script setup>
import MessageContent from './MessageContent.vue'
import DialogContent from './DialogContent.vue'
import LoadingBar from './LoadingBar.vue'
import { useAppStore } from '@/store/modules/app'
const appStore = useAppStore()
</script>
<template>
<n-config-provider :theme-overrides="appStore.themeOverrides">
<n-loading-bar-provider>
<loading-bar />
<n-dialog-provider>
<dialog-content />
<n-message-provider>
<message-content />
<slot></slot>
</n-message-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>

View File

@@ -0,0 +1,55 @@
<template>
<n-config-provider :theme-overrides="themStore.naiveThemeOverrides">
<n-loading-bar-provider>
<n-dialog-provider>
<n-notification-provider>
<n-message-provider>
<slot></slot>
<NaiveProviderContent />
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>
<script setup>
import { defineComponent, h } from 'vue'
import { useLoadingBar, useDialog, useMessage, useNotification } from 'naive-ui'
import { useCssVar } from '@vueuse/core'
import { useThemeStore } from '@/store/modules/theme'
import { setupMessage, setupDialog } from '@/utils/common/naiveTools'
const themStore = useThemeStore()
watch(
() => themStore.naiveThemeOverrides.common,
(vars) => {
for (const key in vars) {
useCssVar(`--${key}`, document.documentElement).value = vars[key]
if (key === 'primaryColor') {
window.localStorage.setItem('__THEME_COLOR__', vars[key])
}
}
},
{ immediate: true }
)
// 挂载naive组件的方法至window, 以便在全局使用
function setupNaiveTools() {
window.$loadingBar = useLoadingBar()
window.$notification = useNotification()
window.$message = setupMessage(useMessage())
window.$dialog = setupDialog(useDialog())
}
const NaiveProviderContent = defineComponent({
setup() {
setupNaiveTools()
},
render() {
return h('div')
},
})
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div ref="wrapper" class="tags-wrapper" @mousewheel.prevent="handleMouseWheel">
<template v-if="showArrow && isOverflow">
<div class="left" @click="handleMouseWheel({ wheelDelta: 50 })">
<icon-ic:baseline-keyboard-arrow-left />
</div>
<div class="right" @click="handleMouseWheel({ wheelDelta: -50 })">
<icon-ic:baseline-keyboard-arrow-right />
</div>
</template>
<div
ref="content"
class="tags-content"
:class="{ overflow: isOverflow && showArrow }"
:style="{
height: height + 'px',
transform: `translateX(${translateX}px)`,
}"
>
<slot />
</div>
</div>
</template>
<script setup>
import { debounce } from '@/utils'
import { isNullOrUndef } from '@/utils/is'
defineProps({
height: {
type: Number,
default: 50,
},
showArrow: {
type: Boolean,
default: true,
},
})
onMounted(() => {
refreshIsOverflow()
})
const translateX = ref(0)
const content = ref(null)
const wrapper = ref(null)
const isOverflow = ref(false)
function refreshIsOverflow(isIncrease) {
isOverflow.value = content.value.offsetWidth > wrapper.value.offsetWidth
if (isNullOrUndef(isIncrease)) return
if (isOverflow.value) {
handleMouseWheel({ wheelDelta: isIncrease ? -100 : 100 })
} else if (!isIncrease && translateX.value < 0) {
handleMouseWheel({ wheelDelta: 100 })
}
}
function handleMouseWheel(e) {
const { wheelDelta } = e
const wrapperWidth = wrapper.value.offsetWidth
const contentWidth = content.value.offsetWidth
/**
* @wheelDelta 平行滚动的值 >0 右移 <0: 左移
* @translateX 内容translateX的值
* @wrapperWidth 容器的宽度
* @contentWidth 内容的宽度
*/
if (wheelDelta < 0 && -translateX.value > contentWidth - wrapperWidth + 10) {
return
}
if (wheelDelta > 0 && translateX.value > 10) {
return
}
translateX.value += wheelDelta
resetTranslateX(wrapperWidth, contentWidth)
}
const resetTranslateX = debounce(function (wrapperWidth, contentWidth) {
if (!isOverflow.value) {
translateX.value = 0
} else if (-translateX.value > contentWidth - wrapperWidth) {
translateX.value = wrapperWidth - contentWidth
} else if (translateX.value > 0) {
translateX.value = 0
}
}, 200)
defineExpose({
refreshIsOverflow,
})
</script>
<style lang="scss" scoped>
.tags-wrapper {
display: flex;
background-color: #fff;
position: sticky;
top: 0;
z-index: 9;
overflow: hidden;
.tags-content {
padding: 0 10px;
display: flex;
align-items: center;
flex-wrap: nowrap;
transition: transform 0.5s;
&.overflow {
padding-left: 30px;
padding-right: 30px;
}
}
.left,
.right {
background-color: #fff;
position: absolute;
top: 0;
bottom: 0;
margin: auto;
width: 20px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: 1px solid #e0e0e6;
border-radius: 2px;
z-index: 2;
cursor: pointer;
}
.left {
left: 0;
}
.right {
right: 0;
}
}
</style>

View File

@@ -2,15 +2,16 @@
<router-view v-slot="{ Component, route }">
<transition name="fade-slide" mode="out-in" appear>
<keep-alive :include="keepAliveRouteNames">
<component :is="Component" :key="route.path" />
<component :is="Component" v-if="appStore.reloadFlag" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/store/modules/app'
const appStore = useAppStore()
const router = useRouter()
const allRoutes = router.getRoutes()
const keepAliveRouteNames = computed(() => {

View File

@@ -1,18 +0,0 @@
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const { currentRoute } = router
function handleBreadClick(path) {
if (path === currentRoute.value.path) return
router.push(path)
}
</script>
<template>
<n-breadcrumb>
<n-breadcrumb-item v-for="item in currentRoute.matched" :key="item.path" @click="handleBreadClick(item.path)">
{{ item.meta.title }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>

View File

@@ -1,96 +0,0 @@
<script setup>
import { useUserStore } from '@/store/modules/user'
import { useRouter } from 'vue-router'
import { resetRouter } from '@/router'
import { usePermissionStore } from '@/store/modules/permission'
import { NOT_FOUND_ROUTE } from '@/router/routes'
const userStore = useUserStore()
const router = useRouter()
const options = [
{
label: '切换角色',
key: 'switchRole',
},
{
label: '退出登录',
key: 'logout',
},
]
function handleSelect(key) {
if (key === 'logout') {
logout()
} else if (key === 'switchRole') {
switchRole()
}
}
function logout() {
userStore.logout()
$message.success('已退出登录')
router.push({ path: '/login' })
}
function switchRole() {
const permissionStore = usePermissionStore()
const users = [
{
id: 1,
name: '大脸怪(admin)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
email: 'Ronnie@123.com',
role: ['admin'],
},
{
id: 2,
name: '大脸怪(editor)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
email: 'Ronnie@123.com',
role: ['editor'],
},
{
id: 3,
name: '访客(guest)',
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
role: [],
},
]
const switchUser = users[+userStore.userId % users.length]
resetRouter()
userStore.setUserInfo(switchUser)
const accessRoutes = permissionStore.generateRoutes(switchUser.role)
accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
router.addRoute(NOT_FOUND_ROUTE)
$message.success(`${switchUser.name}`)
}
</script>
<template>
<n-dropdown :options="options" @select="handleSelect">
<div class="avatar">
<img :src="userStore.avatar" />
<span>{{ userStore.name }}</span>
</div>
</n-dropdown>
</template>
<style lang="scss" scoped>
.avatar {
display: flex;
align-items: center;
cursor: pointer;
img {
width: 100%;
width: 25px;
height: 25px;
border-radius: 50%;
margin-right: 10px;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<n-breadcrumb>
<n-breadcrumb-item v-for="item in route.matched" :key="item.path" @click="handleBreadClick(item.path)">
{{ item.meta.title }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>
<script setup>
const router = useRouter()
const route = useRoute()
function handleBreadClick(path) {
if (path === route.path) return
router.push(path)
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<n-icon mr20 size="18" style="cursor: pointer" @click="toggle">
<icon-ant-design:fullscreen-outlined v-if="isFullscreen" />
<icon-ant-design:fullscreen-outlined v-else />
</n-icon>
</template>
<script setup>
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, toggle } = useFullscreen()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<n-icon mr20 size="18" style="cursor: pointer" @click="handleLinkClick">
<icon-mdi:github />
</n-icon>
</template>
<script setup>
function handleLinkClick() {
window.open('https://github.com/zclzone/vue-naive-admin')
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,12 @@
<template>
<n-icon size="20" style="cursor: pointer" @click="appStore.switchCollapsed">
<icon-mdi:format-indent-increase v-if="appStore.collapsed" />
<icon-mdi:format-indent-decrease v-else />
</n-icon>
</template>
<script setup>
import { useAppStore } from '@/store/modules/app'
const appStore = useAppStore()
</script>

View File

@@ -0,0 +1,52 @@
<template>
<n-dropdown :options="options" @select="handleSelect">
<div class="avatar">
<img :src="userStore.avatar" />
<span>{{ userStore.name }}</span>
</div>
</n-dropdown>
</template>
<script setup>
import { useUserStore } from '@/store/modules/user'
import { renderIcon } from '@/utils/icon'
const userStore = useUserStore()
const options = [
{
label: '退出登录',
key: 'logout',
icon: renderIcon('mdi:exit-to-app', { size: '14px' }),
},
]
function handleSelect(key) {
if (key === 'logout') {
$dialog.confirm({
title: '提示',
type: 'info',
content: '确认退出?',
confirm() {
userStore.logout()
$message.success('已退出登录')
},
})
}
}
</script>
<style lang="scss" scoped>
.avatar {
display: flex;
align-items: center;
cursor: pointer;
img {
width: 100%;
width: 35px;
height: 35px;
border-radius: 50%;
margin-right: 10px;
}
}
</style>

View File

@@ -1,21 +1,39 @@
<script setup>
import BreadCrumb from './BreadCrumb.vue'
import HeaderAction from './HeaderAction.vue'
</script>
<template>
<header class="header">
<bread-crumb />
<header-action />
<div class="h-left">
<MenuCollapse />
<BreadCrumb ml-15 />
</div>
<div class="h-right">
<GithubSite />
<FullScreen />
<UserAvatar />
</div>
</header>
</template>
<script setup>
import BreadCrumb from './components/BreadCrumb.vue'
import MenuCollapse from './components/MenuCollapse.vue'
import FullScreen from './components/FullScreen.vue'
import UserAvatar from './components/UserAvatar.vue'
import GithubSite from './components/GithubSite.vue'
</script>
<style lang="scss" scoped>
.header {
padding: 0 35px;
padding: 0 15px;
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
.h-left {
display: flex;
align-items: center;
}
.h-right {
display: flex;
align-items: center;
}
}
</style>

View File

@@ -1,31 +0,0 @@
<script setup>
import { LastfmSquare } from '@vicons/fa'
const title = import.meta.env.VITE_APP_TITLE
</script>
<template>
<div class="logo">
<n-icon size="36" color="#316c72">
<lastfm-square />
</n-icon>
<router-link to="/">
<n-gradient-text type="primary">{{ title }}</n-gradient-text>
</router-link>
</div>
</template>
<style lang="scss" scoped>
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
a {
margin-left: 5px;
.n-gradient-text {
font-size: 14px;
font-weight: bold;
}
}
}
</style>

View File

@@ -1,137 +0,0 @@
<script setup>
import { useRouter } from 'vue-router'
import { computed, h } from 'vue'
import { usePermissionStore } from '@/store/modules/permission'
import { NIcon } from 'naive-ui'
import { ListAlt, CircleRegular } from '@vicons/fa'
import { isExternal } from '@/utils/is'
const router = useRouter()
const permissionStore = usePermissionStore()
const { currentRoute } = router
const menuOptions = computed(() => {
return generateOptions(permissionStore.routes, '')
})
function resolvePath(basePath, path) {
if (isExternal(path)) return path
return (
'/' +
[basePath, path]
.filter((path) => !!path && path !== '/')
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
.join('/')
)
}
function renderIcon(icon, props = { size: 12 }) {
return () => h(NIcon, { ...props }, { default: () => h(icon) })
}
function isSingleRoute(route) {
let isSingle = true
let curRoute = route
while (curRoute.children && curRoute.children.length) {
if (curRoute.children.length > 1) {
isSingle = false
break
}
if (curRoute.children.length === 1) {
curRoute = curRoute.children[0]
}
}
return isSingle
}
function generateOptions(routes, basePath) {
let options = []
routes.forEach((route) => {
if (route.name && !route.isHidden) {
let curOption = {
label: (route.meta && route.meta.title) || route.name,
key: route.name,
path: resolvePath(basePath, route.path),
}
if (route.children && route.children.length) {
curOption.icon = renderIcon(route.meta?.icon || ListAlt, { size: 16 })
curOption.children = generateOptions(route.children, resolvePath(basePath, route.path))
} else {
curOption.icon = (route.meta?.icon && renderIcon(route.meta?.icon)) || renderIcon(CircleRegular, { size: 8 })
}
options.push(curOption)
}
})
return options
}
function handleMenuSelect(key, item) {
if (isExternal(item.path)) {
window.open(item.path)
} else {
router.push(item.path)
}
// 通过path重定向
// router.push({
// path: '/redirect',
// query: { redirect: item.path },
// })
}
</script>
<template>
<n-menu
class="side-menu"
accordion
:indent="12"
:root-indent="12"
:options="menuOptions"
:value="(currentRoute.meta && currentRoute.meta.activeMenu) || currentRoute.name"
@update:value="handleMenuSelect"
/>
</template>
<style lang="scss">
.n-menu {
margin-top: 10px;
padding-left: 10px;
.n-menu-item {
margin-top: 0;
position: relative;
&::before {
left: 0;
right: 0;
border-radius: 0;
background-color: unset !important;
}
&:hover,
&.n-menu-item--selected {
border-radius: 0 !important;
&::before {
border-right: 3px solid $primaryColor;
background-color: #16243a;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba($primaryColor, 0.3) 100%);
}
}
}
.n-menu-item-content-header {
font-size: 14px;
font-weight: bold;
}
.n-submenu-children {
.n-menu-item-content-header {
font-size: 14px;
font-weight: normal;
position: relative;
overflow: visible !important;
}
}
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<router-link h-60 f-c-c to="/">
<icon-custom-logo text-36></icon-custom-logo>
<h2 v-show="!appStore.collapsed" ml-10 color-primary text-16 font-bold max-w-140 flex-shrink-0>
{{ title }}
</h2>
</router-link>
</template>
<script setup>
import { useAppStore } from '@/store/modules/app'
const title = import.meta.env.VITE_APP_TITLE
const appStore = useAppStore()
</script>

View File

@@ -0,0 +1,110 @@
<template>
<n-menu
class="side-menu"
accordion
:indent="18"
:collapsed-icon-size="22"
:collapsed-width="64"
:options="menuOptions"
:value="(currentRoute.meta && currentRoute.meta.activeMenu) || currentRoute.name"
@update:value="handleMenuSelect"
/>
</template>
<script setup>
import { usePermissionStore } from '@/store/modules/permission'
import { isExternal } from '@/utils/is'
import { useAppStore } from '@/store/modules/app'
import { renderIcon } from '@/utils/icon'
const router = useRouter()
const permissionStore = usePermissionStore()
const appStore = useAppStore()
const { currentRoute } = router
const menuOptions = computed(() => {
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.index - b.index)
})
function resolvePath(basePath, path) {
if (isExternal(path)) return path
return (
'/' +
[basePath, path]
.filter((path) => !!path && path !== '/')
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
.join('/')
)
}
function getMenuItem(route, basePath = '') {
let menuItem = {
label: (route.meta && route.meta.title) || route.name,
key: route.name,
path: resolvePath(basePath, route.path),
icon: route.meta?.icon ? renderIcon(route.meta?.icon, { size: 16 }) : renderIcon('mdi:circle-outline', { size: 8 }),
index: route.meta?.index || 0,
}
const visibleChildren = route.children ? route.children.filter((item) => item.name && !item.isHidden) : []
if (!visibleChildren.length) return menuItem
if (visibleChildren.length === 1) {
// 单个子路由处理
const singleRoute = visibleChildren[0]
menuItem = {
label: singleRoute.meta?.title || singleRoute.name,
key: singleRoute.name,
path: resolvePath(menuItem.path, singleRoute.path),
icon: singleRoute.meta?.icon
? renderIcon(singleRoute.meta?.icon, { size: 16 })
: renderIcon('mdi:circle-outline', { size: 8 }),
index: menuItem.index,
}
const visibleItems = singleRoute.children ? singleRoute.children.filter((item) => item.name && !item.isHidden) : []
if (visibleItems.length === 1) {
menuItem = getMenuItem(visibleItems[0], menuItem.path)
} else if (visibleItems.length > 1) {
menuItem.children = visibleItems.map((item) => getMenuItem(item, menuItem.path)).sort((a, b) => a.index - b.index)
}
} else {
menuItem.children = visibleChildren
.map((item) => getMenuItem(item, menuItem.path))
.sort((a, b) => a.index - b.index)
}
return menuItem
}
function handleMenuSelect(key, item) {
if (isExternal(item.path)) {
window.open(item.path)
} else {
if (item.path === currentRoute.value.path && !currentRoute.value.meta?.keepAlive) {
appStore.reloadPage()
} else {
router.push(item.path)
}
}
}
</script>
<style lang="scss">
.side-menu:not(.n-menu--collapsed) {
.n-menu-item-content {
&::before {
left: 5px;
right: 5px;
}
&.n-menu-item-content--selected,
&:hover {
&::before {
border-left: 4px solid var(--primaryColor);
}
}
}
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup>
import SideLogo from './SideLogo.vue'
import SideMenu from './SideMenu.vue'
import SideLogo from './components/SideLogo.vue'
import SideMenu from './components/SideMenu.vue'
</script>
<template>
<side-logo />
<side-menu />
<SideLogo />
<SideMenu />
</template>

View File

@@ -0,0 +1,126 @@
<template>
<n-dropdown
:show="dropdownShow"
:options="options"
:x="x"
:y="y"
placement="bottom-start"
@clickoutside="handleHideDropdown"
@select="handleSelect"
/>
</template>
<script setup>
import { useTagsStore } from '@/store/modules/tags'
import { renderIcon } from '@/utils/icon'
import { useAppStore } from '@/store/modules/app'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
currentPath: {
type: String,
default: '',
},
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:show'])
const tagsStore = useTagsStore()
const appStore = useAppStore()
const options = computed(() => [
{
label: '重新加载',
key: 'reload',
disabled: props.currentPath !== tagsStore.activeTag,
icon: renderIcon('mdi:refresh', { size: '14px' }),
},
{
label: '关闭',
key: 'close',
disabled: tagsStore.tags.length <= 1,
icon: renderIcon('mdi:close', { size: '14px' }),
},
{
label: '关闭其他',
key: 'close-other',
disabled: tagsStore.tags.length <= 1,
icon: renderIcon('mdi:arrow-expand-horizontal', { size: '14px' }),
},
{
label: '关闭左侧',
key: 'close-left',
disabled: tagsStore.tags.length <= 1 || props.currentPath === tagsStore.tags[0].path,
icon: renderIcon('mdi:arrow-expand-left', { size: '14px' }),
},
{
label: '关闭右侧',
key: 'close-right',
disabled: tagsStore.tags.length <= 1 || props.currentPath === tagsStore.tags[tagsStore.tags.length - 1].path,
icon: renderIcon('mdi:arrow-expand-right', { size: '14px' }),
},
])
const dropdownShow = computed({
get() {
return props.show
},
set(show) {
emit('update:show', show)
},
})
const actionMap = new Map([
[
'reload',
() => {
appStore.reloadPage()
},
],
[
'close',
() => {
tagsStore.removeTag(props.currentPath)
},
],
[
'close-other',
() => {
tagsStore.removeOther(props.currentPath)
},
],
[
'close-left',
() => {
tagsStore.removeLeft(props.currentPath)
},
],
[
'close-right',
() => {
tagsStore.removeRight(props.currentPath)
},
],
])
function handleHideDropdown() {
dropdownShow.value = false
}
function handleSelect(key) {
const actionFn = actionMap.get(key)
actionFn && actionFn()
handleHideDropdown()
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<ScrollX ref="scrollX" :height="useTheme.tags.height">
<n-tag
v-for="tag in tagsStore.tags"
:key="tag.path"
:type="tagsStore.activeTag === tag.path ? 'primary' : 'default'"
:closable="tagsStore.tags.length > 1"
@click="handleTagClick(tag.path)"
@close.stop="tagsStore.removeTag(tag.path)"
@contextmenu.prevent="handleContextMenu($event, tag)"
>
{{ tag.title }}
</n-tag>
</ScrollX>
<ContextMenu
v-model:show="contextMenuOption.show"
:current-path="contextMenuOption.currentPath"
:x="contextMenuOption.x"
:y="contextMenuOption.y"
/>
</template>
<script setup name="Tags">
import ContextMenu from './ContextMenu.vue'
import { useTagsStore } from '@/store/modules/tags'
import { useThemeStore } from '@/store/modules/theme'
import ScrollX from '@/components/common/ScrollX.vue'
const route = useRoute()
const router = useRouter()
const tagsStore = useTagsStore()
const useTheme = useThemeStore()
const contextMenuOption = reactive({
show: false,
x: 0,
y: 0,
currentPath: '',
})
watch(
() => route.path,
() => {
const { name, path } = route
const title = route.meta?.title
tagsStore.addTag({ name, path, title })
},
{ immediate: true }
)
const scrollX = ref(null)
watch(
() => tagsStore.tags,
async (newVal, oldVal) => {
await nextTick()
scrollX.value?.refreshIsOverflow(newVal.length > oldVal.length)
}
)
const handleTagClick = (path) => {
tagsStore.setActiveTag(path)
router.push(path)
}
function showContextMenu() {
contextMenuOption.show = true
}
function hideContextMenu() {
contextMenuOption.show = false
}
function setContextMenu(x, y, currentPath) {
Object.assign(contextMenuOption, { x, y, currentPath })
}
// 右击菜单
async function handleContextMenu(e, tagItem) {
const { clientX, clientY } = e
hideContextMenu()
setContextMenu(clientX, clientY, tagItem.path)
await nextTick()
showContextMenu()
}
</script>
<style lang="scss">
.n-tag {
padding: 0 15px;
margin: 0 5px;
cursor: pointer;
.n-tag__close {
margin-left: 5px;
box-sizing: content-box;
font-size: 12px;
padding: 2px;
border-radius: 50%;
transition: all 0.7s;
&:hover {
color: #fff;
background-color: var(--primaryColor);
}
}
&:hover {
color: var(--primaryColor);
}
}
</style>

View File

@@ -1,28 +1,50 @@
<script setup>
import AppHeader from './components/header/index.vue'
import SideMenu from './components/sidebar/index.vue'
import AppMain from './components/AppMain.vue'
</script>
<template>
<div class="layout">
<n-layout has-sider position="absolute">
<n-layout-sider :width="200" :collapsed-width="0" :native-scrollbar="false">
<side-menu />
</n-layout-sider>
<n-layout>
<n-layout-header style="height: 100px; background-color: #f5f6fb">
<app-header />
</n-layout-header>
<n-layout
position="absolute"
content-style="padding: 0 35px 35px;height: 100%;"
style="top: 100px; background-color: #f5f6fb"
:native-scrollbar="false"
>
<app-main />
</n-layout>
<n-layout has-sider style="height: 100%">
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="220"
:native-scrollbar="false"
:collapsed="appStore.collapsed"
>
<SideBar />
</n-layout-sider>
<n-layout>
<n-layout-header :style="{ height: useTheme.header.height + 'px' }">
<AppHeader />
</n-layout-header>
<n-layout style="background-color: #f5f6fb" :style="`height: calc(100% - ${useTheme.header.height}px)`">
<AppTags v-if="useTheme.tags.visible" />
<AppMain
class="cur-scroll border-t bc-eee"
:style="{
height: `calc(100% - ${useTheme.tags.visible ? useTheme.tags.height : 0}px)`,
overflow: 'auto',
}"
/>
</n-layout>
</n-layout>
</div>
</n-layout>
</template>
<script setup>
import AppHeader from './components/header/index.vue'
import SideBar from './components/sidebar/index.vue'
import AppMain from './components/AppMain.vue'
import AppTags from './components/tags/index.vue'
import { useThemeStore } from '@/store/modules/theme'
import { useAppStore } from '@/store/modules/app'
const useTheme = useThemeStore()
const appStore = useAppStore()
</script>
<style lang="scss" scoped>
.n-layout-header {
height: 60px;
background-color: #fff;
border-bottom: 1px solid #eee;
}
</style>

View File

@@ -1,19 +1,21 @@
import '@/styles/reset.css'
import '@/styles/variables.css'
import '@/styles/index.scss'
import 'uno.css'
import { createApp } from 'vue'
import App from './App.vue'
import { setupRouter } from '@/router'
import { setupStore } from '@/store'
import App from './App.vue'
async function bootstrap() {
function setupApp() {
const app = createApp(App)
setupStore(app)
setupRouter(app)
app.mount('#app', true)
app.mount('#app')
}
bootstrap()
setupApp()

View File

@@ -1,7 +1,9 @@
import { createPageLoadingGuard } from './pageLoadingGuard'
import { createPermissionGuard } from './permissionGuard'
import { createPageLoadingGuard } from './page-loading-guard'
import { createPageTitleGuard } from './page-title-guard'
import { createPermissionGuard } from './permission-guard'
export function setupRouterGuard(router) {
createPageLoadingGuard(router)
createPermissionGuard(router)
createPageTitleGuard(router)
}

View File

@@ -1,15 +1,15 @@
export function createPageLoadingGuard(router) {
router.beforeEach(() => {
$loadingBar.start()
window.$loadingBar?.start()
})
router.afterEach(() => {
setTimeout(() => {
$loadingBar.finish()
window.$loadingBar?.finish()
}, 200)
})
router.onError(() => {
$loadingBar.error()
window.$loadingBar?.error()
})
}

View File

@@ -0,0 +1,12 @@
const baseTitle = import.meta.env.VITE_APP_TITLE
export function createPageTitleGuard(router) {
router.afterEach((to) => {
const pageTitle = to.meta?.title
if (pageTitle) {
document.title = `${pageTitle} | ${baseTitle}`
} else {
document.title = baseTitle
}
})
}

View File

@@ -2,6 +2,7 @@ import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission'
import { NOT_FOUND_ROUTE } from '@/router/routes'
import { getToken, refreshAccessToken, removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
const WHITE_LIST = ['/login', '/redirect']
export function createPermissionGuard(router) {
@@ -18,19 +19,18 @@ export function createPermissionGuard(router) {
refreshAccessToken()
next()
} else {
try {
await userStore.getUserInfo()
const accessRoutes = permissionStore.generateRoutes(userStore.role)
accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
router.addRoute(NOT_FOUND_ROUTE)
next({ ...to, replace: true })
} catch (error) {
await userStore.getUserInfo().catch((error) => {
removeToken()
$message.error(error)
next({ path: '/login', query: { ...to.query, redirect: to.path } })
}
toLogin()
$message.error(error.message || '获取用户信息失败!')
return
})
const accessRoutes = permissionStore.generateRoutes(userStore.role)
accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
router.addRoute(NOT_FOUND_ROUTE)
next({ ...to, replace: true })
}
}
} else {

View File

@@ -1,10 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import { setupRouterGuard } from './guard'
import { basicRoutes } from './routes'
const isHash = !!import.meta.env.VITE_APP_USE_HASH
export const router = createRouter({
history: createWebHistory('/'),
routes: basicRoutes,
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
routes: [],
scrollBehavior: () => ({ left: 0, top: 0 }),
})
@@ -19,6 +20,9 @@ export function resetRouter() {
}
export function setupRouter(app) {
basicRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
app.use(router)
setupRouterGuard(router)
}

View File

@@ -1,6 +1,5 @@
import Layout from '@/layout/index.vue'
import Home from '@/views/dashboard/index.vue'
import { ChartBar, Dove, Github, HouseDamage, Link, TimesCircle } from '@vicons/fa'
export const basicRoutes = [
{
@@ -33,64 +32,88 @@ export const basicRoutes = [
},
{
name: 'DASHBOARD',
name: 'Dashboard',
path: '/',
component: Layout,
redirect: '/home',
meta: {
title: 'Dashboard',
icon: ChartBar,
icon: 'mdi:chart-bar',
},
children: [
{
name: 'HOME',
name: 'Home',
path: 'home',
component: Home,
meta: {
title: '首页',
icon: HouseDamage,
icon: 'mdi:home',
},
},
],
},
{
name: 'TEST',
name: 'ErrorPage',
path: '/error-page',
component: Layout,
redirect: '/error-page/404',
meta: {
title: '错误页',
icon: 'mdi:alert-circle-outline',
index: 4,
},
children: [
{
name: 'ERROR-404',
path: '404',
component: () => import('@/views/error-page/404.vue'),
meta: {
title: '404',
icon: 'mdi:alert-circle-outline',
},
},
],
},
{
name: 'Test',
path: '/test',
component: Layout,
redirect: '/test/unocss',
meta: {
title: '基础功能测试',
icon: 'mdi:menu',
},
children: [
{
name: 'UNOCSS',
name: 'Unocss',
path: 'unocss',
component: () => import('@/views/test-page/TestUnocss.vue'),
component: () => import('@/views/test-page/unocss/index.vue'),
meta: {
title: '测试unocss',
},
},
{
name: 'MESSAGE',
name: 'Message',
path: 'message',
component: () => import('@/views/test-page/TestMessage.vue'),
component: () => import('@/views/test-page/message/index.vue'),
meta: {
title: '测试Message',
},
},
{
name: 'DIALOG',
name: 'Dialog',
path: 'dialog',
component: () => import('@/views/test-page/TestDialog.vue'),
component: () => import('@/views/test-page/dialog/index.vue'),
meta: {
title: '测试Dialog',
},
},
{
name: 'TEST-KEEP-ALIVE',
name: 'TestKeepAlive',
path: 'keep-alive',
component: () => import('@/views/test-page/TestKeepAlive.vue'),
component: () => import('@/views/test-page/keep-alive/index.vue'),
meta: {
title: '测试Keep-Alive',
keepAlive: true,
@@ -100,56 +123,36 @@ export const basicRoutes = [
},
{
name: 'ERROR-PAGE',
path: '/error-page',
component: Layout,
redirect: '/error-page/404',
meta: {
title: '错误页',
icon: TimesCircle,
},
children: [
{
name: 'ERROR-404',
path: '404',
component: () => import('@/views/error-page/404.vue'),
meta: {
title: '404',
},
},
],
},
{
name: 'EXTERNAL-LINK',
name: 'ExternalLink',
path: '/external-link',
component: Layout,
meta: {
title: '外部链接',
icon: Link,
icon: 'mdi:link-variant',
},
children: [
{
name: 'LINK-GITHUB-SRC',
name: 'LinkGithubSrc',
path: 'https://github.com/zclzone/vue-naive-admin',
meta: {
title: '源码 - github',
icon: Github,
icon: 'mdi:github',
},
},
{
name: 'LINK-GITEE-SRC',
name: 'LinkGiteeSrc',
path: 'https://gitee.com/zclzone/vue-naive-admin',
meta: {
title: '源码 - gitee',
icon: 'simple-icons:gitee',
},
},
{
name: 'LINK-DOCS',
path: 'https://www.yuque.com/qszone/vue-naive-admin',
name: 'LinkDocs',
path: 'https://zclzone.github.io/vue-naive-admin-docs',
meta: {
title: '文档 - 语雀',
icon: Dove,
title: '文档 - vuepress',
icon: 'mdi:vuejs',
},
},
],
@@ -157,7 +160,7 @@ export const basicRoutes = [
]
export const NOT_FOUND_ROUTE = {
name: 'NOT_FOUND',
name: 'NotFound',
path: '/:pathMatch(.*)*',
redirect: '/404',
isHidden: true,

View File

@@ -2,7 +2,7 @@ import Layout from '@/layout/index.vue'
export default [
{
name: 'EXAMPLE',
name: 'Example',
path: '/example',
component: Layout,
redirect: '/example/table',
@@ -12,17 +12,18 @@ export default [
},
children: [
{
name: 'EXAMPLE-TABLE',
name: 'Table',
path: 'table',
component: () => import('@/views/examples/table/index.vue'),
redirect: '/example/table/post',
meta: {
title: '表格',
role: ['admin'],
icon: 'mdi:table',
},
children: [
{
name: 'POST-LIST',
name: 'PostList',
path: 'post',
component: () => import('@/views/examples/table/post/index.vue'),
meta: {
@@ -31,9 +32,9 @@ export default [
},
},
{
name: 'POST-CREATE',
name: 'PostCreate',
path: 'post-create',
component: () => import('@/views/examples/table/post/post-create.vue'),
component: () => import('@/views/examples/table/post/PostCreate.vue'),
meta: {
title: '创建文章',
role: ['admin'],

1
src/settings/index.js Normal file
View File

@@ -0,0 +1 @@
export { default as themeSettings } from './theme.json'

17
src/settings/theme.json Normal file
View File

@@ -0,0 +1,17 @@
{
"tags": {
"visible": true,
"height": 50
},
"header": {
"height": 60
},
"naiveThemeOverrides": {
"common": {
"primaryColor": "#316C72FF",
"primaryColorHover": "#316C72E3",
"primaryColorPressed": "#2B4C59FF",
"primaryColorSuppl": "#316C7263"
}
}
}

View File

@@ -3,15 +3,27 @@ import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state() {
return {
themeOverrides: {
common: {
primaryColor: '#316c72',
primaryColorSuppl: '#316c72',
primaryColorHover: '#316c72',
successColorHover: '#316c72',
successColorSuppl: '#316c72',
},
},
reloadFlag: true,
collapsed: false,
}
},
actions: {
async reloadPage() {
$loadingBar.start()
this.reloadFlag = false
await nextTick()
this.reloadFlag = true
setTimeout(() => {
document.documentElement.scrollTo({ left: 0, top: 0 })
$loadingBar.finish()
}, 100)
},
switchCollapsed() {
this.collapsed = !this.collapsed
},
setCollapsed(collapsed) {
this.collapsed = collapsed
},
},
})

View File

@@ -38,6 +38,9 @@ export const usePermissionStore = defineStore('permission', {
routes() {
return basicRoutes.concat(this.accessRoutes)
},
menus() {
return this.routes.filter((route) => route.name && !route.isHidden)
},
},
actions: {
generateRoutes(role = []) {

View File

@@ -0,0 +1,7 @@
import { createSessionStorage } from '@/utils/cache'
export const tagsSS = createSessionStorage({ prefixKey: 'tag_' })
export const activeTag = tagsSS.get('activeTag')
export const tags = tagsSS.get('tags')
export const WITHOUT_TAG_PATHS = ['/404', '/login', '/redirect']

View File

@@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { tagsSS, activeTag, tags, WITHOUT_TAG_PATHS } from './helpers'
import { router } from '@/router'
export const useTagsStore = defineStore('tag', {
state() {
return {
tags: tags || [],
activeTag: activeTag || '',
}
},
actions: {
setActiveTag(path) {
this.activeTag = path
tagsSS.set('activeTag', path)
},
setTags(tags) {
this.tags = tags
tagsSS.set('tags', tags)
},
addTag(tag = {}) {
this.setActiveTag(tag.path)
if (WITHOUT_TAG_PATHS.includes(tag.path) || this.tags.some((item) => item.path === tag.path)) return
this.setTags([...this.tags, tag])
},
removeTag(path) {
if (path === this.activeTag) {
const activeIndex = this.tags.findIndex((item) => item.path === path)
if (activeIndex > 0) {
router.push(this.tags[activeIndex - 1].path)
} else {
router.push(this.tags[activeIndex + 1].path)
}
}
this.setTags(this.tags.filter((tag) => tag.path !== path))
},
removeOther(curPath = this.activeTag) {
this.setTags(this.tags.filter((tag) => tag.path === curPath))
if (curPath !== this.activeTag) {
router.push(this.tags[this.tags.length - 1].path)
}
},
removeLeft(curPath) {
const curIndex = this.tags.findIndex((item) => item.path === curPath)
const filterTags = this.tags.filter((item, index) => index >= curIndex)
this.setTags(filterTags)
if (!filterTags.find((item) => item.path === this.activeTag)) {
router.push(filterTags[filterTags.length - 1].path)
}
},
removeRight(curPath) {
const curIndex = this.tags.findIndex((item) => item.path === curPath)
const filterTags = this.tags.filter((item, index) => index <= curIndex)
this.setTags(filterTags)
if (!filterTags.find((item) => item.path === this.activeTag)) {
router.push(filterTags[filterTags.length - 1].path)
}
},
},
})

View File

@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { themeSettings } from '@/settings'
export const useThemeStore = defineStore('theme', {
state() {
return {
tags: themeSettings.tag || { visible: true, height: 50 },
header: themeSettings.header || { height: 60 },
naiveThemeOverrides: themeSettings.naiveThemeOverrides || {
common: {
primaryColor: '#316C72FF',
primaryColorHover: '#316C72E3',
primaryColorPressed: '#2B4C59FF',
primaryColorSuppl: '#316C7263',
},
},
}
},
actions: {
setTabVisible(visible) {
this.tags.visible = visible
},
},
})

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { getUser } from '@/api/user'
import { removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
export const useUserStore = defineStore('user', {
state() {
@@ -31,16 +32,16 @@ export const useUserStore = defineStore('user', {
this.userInfo = { id, name, avatar, role }
return Promise.resolve(res.data)
} else {
return Promise.reject(res.message)
return Promise.reject(res)
}
} catch (error) {
console.error(error)
return Promise.reject(error.message)
return Promise.reject(error)
}
},
logout() {
async logout() {
removeToken()
this.userInfo = {}
toLogin()
},
setUserInfo(userInfo = {}) {
this.userInfo = { ...this.userInfo, ...userInfo }

View File

@@ -1,2 +1,54 @@
@import './reset.scss';
@import './public.scss';
html {
font-size: 4px; // * 1rem = 4px 方便unocss计算在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #f2f2f2;
font-family: 'Encode Sans Condensed', sans-serif;
}
/* router view transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 自定义滚动条样式 */
.cur-scroll {
&::-webkit-scrollbar{
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb{
background-color: transparent;
border-radius: 3px;
}
&::-webkit-scrollbar-corner{
background: #f6f6f6;
}
&:hover {
&::-webkit-scrollbar-thumb {
background: #bfbfbf;
}
&::-webkit-scrollbar-thumb:hover{
background: #999999;
}
}
}

View File

@@ -1,28 +0,0 @@
html {
font-size: 4px; // * 1rem = 4px 方便unocss计算在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #f2f2f2;
font-family: 'Encode Sans Condensed', sans-serif;
}
/* router view transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}

3
src/styles/variables.css Normal file
View File

@@ -0,0 +1,3 @@
:root {
--primaryColor: #316c72;
}

View File

@@ -1,5 +0,0 @@
$primaryColor: #316c72;
:root {
--vh100: 100vh;
}

8
src/utils/auth.js Normal file
View File

@@ -0,0 +1,8 @@
import { router } from '@/router'
export function toLogin() {
router.replace({
path: '/login',
query: { ...router.currentRoute.query, redirect: router.currentRoute.path },
})
}

View File

@@ -1,9 +1,15 @@
import { createWebStorage } from './webStorage'
import { createWebStorage } from './web-storage'
export const createLocalStorage = function (option = {}) {
return createWebStorage({ prefixKey: option.prefixKey || '', storage: localStorage })
return createWebStorage({
prefixKey: option.prefixKey || '',
storage: localStorage,
})
}
export const createSessionStorage = function (option = {}) {
return createWebStorage({ prefixKey: option.prefixKey || '', storage: sessionStorage })
return createWebStorage({
prefixKey: option.prefixKey || '',
storage: sessionStorage,
})
}

View File

@@ -0,0 +1,79 @@
import { isNullOrUndef } from '@/utils/is'
export function setupMessage(NMessage) {
let loadingMessage = null
class Message {
/**
* 规则:
* * loading message只显示一个新的message会替换正在显示的loading message
* * loading message不会自动清除除非被替换成非loading message非loading message默认2秒后自动清除
*/
removeMessage(message, duration = 2000) {
setTimeout(() => {
if (message) {
message.destroy()
message = null
}
}, duration)
}
showMessage(type, content, option = {}) {
if (loadingMessage && loadingMessage.type === 'loading') {
// 如果存在则替换正在显示的loading message
loadingMessage.type = type
loadingMessage.content = content
if (type !== 'loading') {
// 非loading message需设置自动清除
this.removeMessage(loadingMessage, option.duration)
}
} else {
// 不存在正在显示的loading则新建一个message,如果新建的message是loading message则将message赋值存储下来
let message = NMessage[type](content, option)
if (type === 'loading') {
loadingMessage = message
}
}
}
loading(content) {
this.showMessage('loading', content, { duration: 0 })
}
success(content, option = {}) {
this.showMessage('success', content, option)
}
error(content, option = {}) {
this.showMessage('error', content, option)
}
info(content, option = {}) {
this.showMessage('info', content, option)
}
warning(content, option = {}) {
this.showMessage('warning', content, option)
}
}
return new Message()
}
export function setupDialog(NDialog) {
NDialog.confirm = function (option = {}) {
const showIcon = !isNullOrUndef(option.title)
return NDialog[option.type || 'warning']({
showIcon,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: option.confirm,
onNegativeClick: option.cancel,
onMaskClick: option.cancel,
...option,
})
}
return NDialog
}

View File

@@ -1,18 +1,22 @@
import axios from 'axios'
import { setupInterceptor } from './interceptors'
import { repReject, repResolve, reqReject, reqResolve } from './interceptors'
function createAxios(option = {}) {
const defBaseURL = window.__APP__GLOB__CONF__?.VITE_APP_GLOB_BASE_API || import.meta.env.VITE_APP_GLOB_BASE_API
export function createAxios(options = {}) {
const defaultOptions = {
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 12000,
}
const service = axios.create({
timeout: option.timeout || 120000,
baseURL: option.baseURL || defBaseURL,
...defaultOptions,
...options,
})
setupInterceptor(service)
service.interceptors.request.use(reqResolve, reqReject)
service.interceptors.response.use(repResolve, repReject)
return service
}
export const defAxios = createAxios()
export const testAxios = createAxios({
baseURL: window.__APP__GLOB__CONF__?.VITE_APP_GLOB_BASE_API_TEST || import.meta.env.VITE_APP_GLOB_BASE_API_TEST,
baseURL: import.meta.env.VITE_APP_BASE_API_TEST,
})

View File

@@ -1,82 +1,71 @@
import { router } from '@/router'
import { getToken, removeToken } from '@/utils/token'
import { isWithoutToken } from './help'
import { getToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
import { isNullOrUndef } from '@/utils/is'
import { isWithoutToken } from './helpers'
export function setupInterceptor(service) {
service.interceptors.request.use(
async (config) => {
// 防止缓存给get请求加上时间戳
if (config.method === 'get') {
config.params = { ...config.params, t: new Date().getTime() }
}
export function reqResolve(config) {
// 防止缓存给get请求加上时间戳
if (config.method === 'get') {
config.params = { ...config.params, t: new Date().getTime() }
}
// 处理不需要token的请求
if (isWithoutToken(config)) {
return config
}
// 处理不需要token的请求
if (isWithoutToken(config)) {
return config
}
const token = getToken()
if (token) {
/**
* * jwt token
* ! 认证方案: Bearer
*/
config.headers.Authorization = 'Bearer ' + token
const token = getToken()
if (!token) {
/**
* * 未登录或者token过期的情况下
* * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面
*/
toLogin()
return Promise.reject({ code: '-1', message: '未登录' })
}
return config
}
/**
* * 未登录或者token过期的情况下
* * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面
*/
const { currentRoute } = router
router.replace({
path: '/login',
query: { ...currentRoute.query, redirect: currentRoute.path },
})
return Promise.reject({ code: '-1', message: '未登录' })
},
(error) => Promise.reject(error)
)
/**
* * jwt token
* ! 认证方案: Bearer
*/
config.headers.Authorization = config.headers.Authorization || 'Bearer ' + token
service.interceptors.response.use(
(response) => response?.data,
(error) => {
let { code, message } = error.response?.data
return Promise.reject({ code, message })
/**
* TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
*/
switch (code) {
case 401:
// 未登录可能是token过期或者无效了
console.error(message)
removeToken()
const { currentRoute } = router
router.replace({
path: '/login',
query: { ...currentRoute.query, redirect: currentRoute.path },
})
break
case 403:
// 没有权限
console.error(message)
break
case 404:
// 资源不存在
console.error(message)
break
default:
break
}
// 已知错误resolve在业务代码中作提醒未知错误reject捕获错误统一提示接口异常9000以上为业务类型错误需要跟后端确定好
if ([401, 403, 404].includes(code) || code >= 9000) {
return Promise.resolve({ code, message })
} else {
console.error('【err】' + error)
return Promise.reject({ message: '接口异常,请稍后重试!' })
}
}
)
return config
}
export function reqReject(error) {
return Promise.reject(error)
}
export function repResolve(response) {
return response?.data
}
export function repReject(error) {
let { code, message } = error.response?.data || {}
if (isNullOrUndef(code)) {
// 未知错误
code = -1
message = '接口异常!'
} else {
/**
* TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
*/
switch (code) {
case 401:
message = message || '登录已过期'
break
case 403:
message = message || '没有权限'
break
case 404:
message = message || '资源或接口不存在'
break
default:
message = message || '未知异常'
break
}
}
console.error(`${code}${error}`)
return Promise.resolve({ code, message, error })
}

7
src/utils/icon.js Normal file
View File

@@ -0,0 +1,7 @@
import { h } from 'vue'
import { Icon } from '@iconify/vue'
import { NIcon } from 'naive-ui'
export function renderIcon(icon, props = { size: 12 }) {
return () => h(NIcon, props, { default: () => h(Icon, { icon }) })
}

View File

@@ -16,6 +16,10 @@ export function isNull(val) {
return val === null
}
export function isWhitespace(val) {
return val === ''
}
export function isObject(val) {
return !isNull(val) && is(val, 'Object')
}
@@ -64,6 +68,10 @@ export function isNullOrUndef(val) {
return isNull(val) || isUndef(val)
}
export function isNullOrWhitespace(val) {
return isNullOrUndef(val) || isWhitespace(val)
}
export function isEmpty(val) {
if (isArray(val) || isString(val)) {
return val.length === 0
@@ -81,14 +89,14 @@ export function isEmpty(val) {
}
/**
* * 类似sql的isnull函数
* * 第一个参数为null/undefined/''则返回第二个参数作为默认值,否则返回第一个参数
* * 类似mysql的IFNULL函数
* * 第一个参数为null/undefined/'' 则返回第二个参数作为备用值,否则返回第一个参数
* @param {Number|Boolean|String} val
* @param {Number|Boolean|String} replaceVal
* @param {Number|Boolean|String} def
* @returns
*/
export function isNullReplace(val, replaceVal = '') {
return isNullOrUndef(val) || val === '' ? replaceVal : val
export function ifNull(val, def = '') {
return isNullOrWhitespace(val) ? def : val
}
export function isUrl(path) {

View File

@@ -24,14 +24,12 @@ export async function refreshAccessToken() {
return
}
const { time } = tokenItem
if (new Date().getTime() - time > 1000 * 60 * 30) {
try {
const res = await refreshToken()
if (res.code === 0) {
setToken(res.data.token)
}
} catch (error) {
console.error(error)
// token生成或者刷新后30分钟内不执行刷新
if (new Date().getTime() - time <= 1000 * 60 * 30) return
try {
const res = await refreshToken()
if (res.code === 0) {
setToken(res.data.token)
}
}
} catch (error) {}
}

View File

@@ -1,3 +1,56 @@
<template>
<h1>首页</h1>
<div p-15>
<n-card rounded-10>
<div flex items-center>
<img rounded-full width="60" :src="userStore.avatar" />
<div ml-20>
<p text-16>Hello, {{ userStore.name }}</p>
<p mt-5 text-12 op-60>今天又是元气满满的一天</p>
</div>
<div ml-auto flex items-center>
<n-statistic label="待办" :value="4">
<template #suffix> / 10 </template>
</n-statistic>
<n-statistic label="Stars" w-100 ml-80>
<a href="https://github.com/zclzone/vue-naive-admin">
<img allt="stars" src="https://badgen.net/github/stars/zclzone/vue-naive-admin" />
</a>
</n-statistic>
<n-statistic label="Forks" w-100 ml-80>
<a href="https://github.com/zclzone/vue-naive-admin">
<img allt="forks" src="https://badgen.net/github/forks/zclzone/vue-naive-admin" />
</a>
</n-statistic>
</div>
</div>
</n-card>
<n-card title="项目" size="small" :segmented="true" mt-15 rounded-10>
<template #header-extra>
<n-button text type="primary">更多</n-button>
</template>
<div flex flex-wrap justify-between>
<n-card
v-for="i in 10"
:key="i"
class="w-300 flex-shrink-0 mt-10 mb-10 cursor-pointer"
hover:card-shadow
title="Vue Naive Admin"
size="small"
>
<p op-60>一个基于 Vue3.0ViteNaive UI 的轻量级后台管理模板</p>
</n-card>
<div w-300 h-0></div>
<div w-300 h-0></div>
<div w-300 h-0></div>
<div w-300 h-0></div>
</div>
</n-card>
</div>
</template>
<script setup>
import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore()
</script>

View File

@@ -1,21 +1,20 @@
<script setup>
import { useRouter } from 'vue-router'
const { replace } = useRouter()
</script>
<template>
<div class="page-404">
<n-result status="404" description="抱歉,您访问的页面不存在。">
<template #icon>
<img src="@/assets/imgs/404/404.png" width="500" />
<img src="@/assets/images/404.png" width="500" />
</template>
<template #footer>
<n-button @click="replace('/')">返回首页</n-button>
<n-button strong secondary type="primary" @click="replace('/')">返回首页</n-button>
</template>
</n-result>
</div>
</template>
<script setup>
const { replace } = useRouter()
</script>
<style lang="scss" scoped>
.page-404 {
height: 100%;

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