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

110 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
104 changed files with 2692 additions and 1853 deletions

3
.env
View File

@@ -1,6 +1,3 @@
VITE_APP_TITLE = 'Vue Naive Admin' VITE_APP_TITLE = 'Vue Naive Admin'
VITE_PORT = 3100 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"]] VITE_PROXY = [["/api","http://localhost:8080"],["/api-test","localhost:8080"]]
# base api # base api
VITE_APP_GLOB_BASE_API = '/api' VITE_APP_BASE_API = '/api'
# test base 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 VITE_APP_USE_MOCK = true
# base api # base api
VITE_APP_GLOB_BASE_API = '/api' VITE_APP_BASE_API = '/api'
# test base 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 VITE_APP_USE_MOCK = true
# base api # base api
VITE_APP_GLOB_BASE_API = '/api' VITE_APP_BASE_API = '/api'
# test base 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 node_modules
dist dist
public public
package.json

View File

@@ -1,19 +1,8 @@
// * https://zhuanlan.zhihu.com/p/388703150
module.exports = { module.exports = {
root: true, 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'], extends: ['plugin:vue/vue3-recommended', 'plugin:prettier/recommended'],
plugins: ['prettier'],
rules: { rules: {
'prettier/prettier': 'error', 'prettier/prettier': 'warn',
'vue/valid-template-root': 'off', 'vue/valid-template-root': 'off',
'vue/no-multiple-template-root': 'off', 'vue/no-multiple-template-root': 'off',
'vue/multi-word-component-names': [ '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 node_modules
dist dist
*.local *.local
stats.html

View File

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

24
.vscode/settings.json vendored
View File

@@ -3,28 +3,10 @@
"path-intellisense.mappings": { "path-intellisense.mappings": {
"@/": "${workspaceRoot}/src" "@/": "${workspaceRoot}/src"
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": false,
"[jsonc]": { "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "yzhang.markdown-all-in-one"
},
"eslint.validate": ["javascript", "javascriptreact", "typescript"],
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": true
}, },
"editor.formatOnSave": true "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,4 +1,15 @@
## 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>
### 简介 ### 简介
@@ -13,26 +24,30 @@
- 🍒 集成 Naive UI尤大推荐的 UI 组件库,[https://www.naiveui.com](https://www.naiveui.com) - 🍒 集成 Naive UI尤大推荐的 UI 组件库,[https://www.naiveui.com](https://www.naiveui.com)
- 🍑 集成登陆、注销及权限验证 - 🍑 集成登陆、注销及权限验证
- 🍐 集成多环境配置dev、测试、预发布和生产 - 🍐 集成多环境配置dev、测试、生产和github pages环境
- 🍎 集成 Eslint + Prettier代码约束和格式化统一 - 🍎 集成 Eslint + Prettier代码约束和格式化统一
- 🍉 集成 Mock 接口服务dev 环境和发布环境都支持,可动态配置是否启用 mock 服务,不启用时不会加载 mock 包,减少打包体积 - 🍉 集成 Mock 接口服务dev 环境和发布环境都支持,可动态配置是否启用 mock 服务,不启用时不会加载 mock 包,减少打包体积
- 🍇 集成 unocssantfu 大神开源的原子化 css 解决方案,非常轻量,目前我是自己写 scss 样式搭配着 unocss 使用的 - 🍇 集成 unocssantfu 大神开源的原子化 css 解决方案,非常轻量,目前我是自己写 scss 样式搭配着 unocss 使用的
- 🍍 集成 PiniaVuex 的替代方案轻量、简单、易用尤大已表示不会有Vuex5或者说pinia就是Vuex5 - 🍍 集成 PiniaVuex 的替代方案轻量、简单、易用尤大已表示不会有Vuex5或者说pinia就是Vuex5
- 📦 集成 Vite 自动导入插件unplugin-vue-components解放双手开发效率直接起飞 - 📦 集成 Vite 自动导入插件unplugin-vue-components解放双手开发效率直接起飞
- 🍏 二次封装 Axios支持多 axios 实例,支持线上环境免重新打包修改 baseURL - 🤹 集成 unplugin-icons插件优雅使用iconify图标
- 🍏 二次封装 Axios支持多 axios 实例
- 🍌 二次封装全局 Dialog、Message、LoadingBar 组件 - 🍌 二次封装全局 Dialog、Message、LoadingBar 组件
- 🍋 二次封装 localStorage 和 sessionStorage支持设置过期时间 - 🍋 二次封装 localStorage 和 sessionStorage支持设置过期时间
## 预览 ### 预览
[template.qszone.com](https://template.qszone.com) [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 Docs](https://zclzone.github.io/vue-naive-admin-docs)
[羽雀文档Vue Naive Admin](https://www.yuque.com/qszone/vue-naive-admin) [羽雀文档Vue Naive Admin](https://www.yuque.com/qszone/vue-naive-admin)
## 构建步骤 ### 构建
```shell ```shell
# 推荐配置git autocrlf 为 false本项目规范使用lf换行符此配置是为防止git自动将源文件转换为crlf # 推荐配置git autocrlf 为 false本项目规范使用lf换行符此配置是为防止git自动将源文件转换为crlf
@@ -52,20 +67,20 @@ pnpm i # 或者 npm i
npm run dev npm run dev
``` ```
## 发布 ### 发布
```shell ```shell
# 构建测试环境 # 构建测试环境
npm run build:test npm run build:test
# 构建预发布环境 # 构建github pages环境
npm run build:staging npm run build:github
# 构建生产环境 # 构建生产环境
npm run build npm run build
``` ```
## 其他指令 ### 其他指令
```shell ```shell
# eslint代码格式检查 # eslint代码格式检查
@@ -78,7 +93,9 @@ npm run lint:fix
npm run preview npm run preview
``` ```
## Git 提交规范 ### 规范
#### git commit 规范
- `feat` 增加新功能 - `feat` 增加新功能
- `fix` 修复问题/BUG - `fix` 修复问题/BUG
@@ -94,3 +111,12 @@ npm run preview
- `types` 类型定义文件更改 - `types` 类型定义文件更改
- `wip` 开发中 - `wip` 开发中
- `mod` 不确定分类的修改 - `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' 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
}

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' import { getEnvConfig, getRootPath } from '../utils'
export function runBuildCNAME() { export function runBuildCNAME() {
const { VITE_APP_GLOB_CNAME } = getEnvConfig() const { VITE_APP_CNAME } = getEnvConfig()
if (!VITE_APP_GLOB_CNAME) return if (!VITE_APP_CNAME) return
try { try {
writeFileSync(getRootPath(`${OUTPUT_DIR}/CNAME`), VITE_APP_GLOB_CNAME) writeFileSync(getRootPath(`${OUTPUT_DIR}/CNAME`), VITE_APP_CNAME)
} catch (error) { } catch (error) {
console.log(chalk.red('CNAME file failed to package:\n' + 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 chalk from 'chalk'
import { runBuildConfig } from './build-config'
import { runBuildCNAME } from './build-cname' import { runBuildCNAME } from './build-cname'
export const runBuild = async () => { export const runBuild = async () => {
try { try {
runBuildConfig()
runBuildCNAME() runBuildCNAME()
console.log(`${chalk.cyan('build successfully!')}`) console.log(`${chalk.cyan('build successfully!')}`)
} catch (error) { } catch (error) {

View File

@@ -2,6 +2,8 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import dotenv from 'dotenv' import dotenv from 'dotenv'
const httpsReg = /^https:\/\//
export function wrapperEnv(envOptions) { export function wrapperEnv(envOptions) {
if (!envOptions) return {} if (!envOptions) return {}
const ret = {} const ret = {}
@@ -14,7 +16,7 @@ export function wrapperEnv(envOptions) {
if (['VITE_PORT'].includes(key)) { if (['VITE_PORT'].includes(key)) {
val = +val val = +val
} }
if (key === 'VITE_PROXY' && val) { if (key === 'VITE_PROXY' && val && typeof val === 'string') {
try { try {
val = JSON.parse(val.replace(/'/g, '"')) val = JSON.parse(val.replace(/'/g, '"'))
} catch (error) { } catch (error) {
@@ -22,15 +24,33 @@ export function wrapperEnv(envOptions) {
} }
} }
ret[key] = val ret[key] = val
if (typeof key === 'string') { if (typeof val === 'string') {
process.env[key] = val process.env[key] = val
} else if (typeof key === 'object') { } else if (typeof val === 'object') {
process.env[key] = JSON.stringify(val) process.env[key] = JSON.stringify(val)
} }
} }
return ret 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,42 +0,0 @@
import vue from '@vitejs/plugin-vue'
/**
* * 扩展setup插件支持在script标签中使用name属性
* usage: <script setup name="MyComp"></script>
*/
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
/**
* * 组件库按需引入插件
* usage: 直接使用组件,无需在任何地方导入组件
*/
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
/**
* * unplugin-icons插件自动引入iconify图标
* usage: https://github.com/antfu/unplugin-icons
* 图标库: https://icones.js.org/
*/
import Icons from 'unplugin-icons/vite'
import { unocss } from './unocss'
import { configHtmlPlugin } from './html'
import { configMockPlugin } from './mock'
export function createVitePlugins(viteEnv, isBuild) {
const plugins = [
vue(),
VueSetupExtend(),
Components({
resolvers: [NaiveUiResolver()],
}),
Icons({ compiler: 'vue3', autoInstall: true }),
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

@@ -8,11 +8,30 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" /> <link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/resource/loading.css" />
<title><%= title %></title> <title><%= title %></title>
</head> </head>
<body> <body>
<div id="app"></div> <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>
</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,31 +1,28 @@
{ {
"name": "vue-naive-admin", "name": "vue-naive-admin",
"version": "0.0.1", "version": "0.4.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"lint": "eslint --ext .js,.vue .", "lint": "eslint --ext .js,.vue .",
"lint:fix": "eslint --fix --ext .js,.vue .", "lint:fix": "eslint --fix --ext .js,.vue .",
"build": "vite build && esno ./build/script", "build": "vite build",
"build:test": "vite build --mode test && esno ./build/script", "build:test": "vite build --mode test",
"build:staging": "vite build --mode staging && esno ./build/script", "build:github": "vite build --mode github && esno ./build/script",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^8.4.2",
"axios": "^0.21.4", "axios": "^0.21.4",
"dayjs": "^1.11.0", "dayjs": "^1.11.0",
"lodash-es": "^4.17.21",
"md-editor-v3": "^1.11.4", "md-editor-v3": "^1.11.4",
"mockjs": "^1.1.0", "mockjs": "^1.1.0",
"pinia": "^2.0.13", "pinia": "^2.0.13",
"vue": "^3.2.31", "vue": "^3.2.31",
"vue-router": "^4.0.14" "vue-router": "^4.0.15"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/mdi": "^1.1.9", "@iconify/json": "^2.1.63",
"@iconify-json/simple-icons": "^1.1.7", "@iconify/vue": "^3.2.1",
"@unocss/preset-attributify": "^0.16.4",
"@unocss/preset-icons": "^0.16.4",
"@unocss/preset-uno": "^0.16.4",
"@vitejs/plugin-vue": "^1.10.2", "@vitejs/plugin-vue": "^1.10.2",
"@vue/compiler-sfc": "^3.2.31", "@vue/compiler-sfc": "^3.2.31",
"chalk": "^5.0.1", "chalk": "^5.0.1",
@@ -36,14 +33,16 @@
"eslint-plugin-vue": "^8.5.0", "eslint-plugin-vue": "^8.5.0",
"esno": "^0.13.0", "esno": "^0.13.0",
"fs-extra": "^10.0.1", "fs-extra": "^10.0.1",
"naive-ui": "^2.27.0", "naive-ui": "^2.30.3",
"prettier": "^2.6.1", "prettier": "^2.6.1",
"rollup-plugin-visualizer": "^5.6.0",
"sass": "^1.49.10", "sass": "^1.49.10",
"unocss": "^0.16.4", "unocss": "^0.38.2",
"unplugin-auto-import": "^0.8.8",
"unplugin-icons": "^0.14.1", "unplugin-icons": "^0.14.1",
"unplugin-vue-components": "^0.17.21", "unplugin-vue-components": "^0.17.21",
"vite": "^2.9.1", "vite": "^2.9.9",
"vite-plugin-html": "^2.1.2", "vite-plugin-html": "^3.2.0",
"vite-plugin-mock": "^2.9.6", "vite-plugin-mock": "^2.9.6",
"vite-plugin-vue-setup-extend": "^0.3.0" "vite-plugin-vue-setup-extend": "^0.3.0"
} }

990
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -7,7 +7,7 @@
</template> </template>
<script setup> <script setup>
import AppProvider from '@/components/AppProvider/index.vue' import AppProvider from '@/components/common/AppProvider.vue'
</script> </script>
<style lang="scss"> <style lang="scss">

View File

Before

Width:  |  Height:  |  Size: 825 B

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,9 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512">
<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"
></path>
</svg>
</template>
<script setup name="IconLogo"></script>

View File

@@ -1,12 +0,0 @@
export { default as IconGitee } from '~icons/simple-icons/gitee'
export { default as IconChart } from '~icons/mdi/chart-bar'
export { default as IconGithub } from '~icons/mdi/github'
export { default as IconVue } from '~icons/mdi/vuejs'
export { default as IconHome } from '~icons/mdi/home'
export { default as IconLink } from '~icons/mdi/link-variant'
export { default as IconAlert } from '~icons/mdi/alert-circle-outline'
export { default as IconCircle } from '~icons/mdi/circle-outline'
export { default as IconMenu } from '~icons/mdi/menu'
export { default as IconLogo } from './IconLogo.vue'

View File

@@ -1,52 +0,0 @@
<template></template>
<script setup>
import { isNullOrUndef } from '@/utils/is'
import { useDialog } from 'naive-ui'
const NDialog = useDialog()
class Dialog {
success(title, option) {
return this.showDialog('success', { title, ...option })
}
warning(title, option) {
return this.showDialog('warning', { title, ...option })
}
error(title, option) {
return this.showDialog('error', { title, ...option })
}
showDialog(type = 'success', option) {
if (isNullOrUndef(option.title)) {
// ! 没有title的情况
option.showIcon = false
}
return NDialog[type]({
positiveText: 'OK',
closable: false,
...option,
})
}
confirm(option = {}) {
return 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>

View File

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

View File

@@ -1,72 +0,0 @@
<template></template>
<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>

View File

@@ -1,23 +0,0 @@
<template>
<n-config-provider :theme-overrides="appStore.themeOverrides">
<n-loading-bar-provider>
<LoadingBar />
<n-dialog-provider>
<DialogContent />
<n-message-provider>
<MessageContent />
<slot></slot>
</n-message-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>
<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>

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

View File

@@ -1,18 +0,0 @@
<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>
<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>

View File

@@ -1,96 +0,0 @@
<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 { 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://assets.qszone.com/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['admin'],
},
{
id: 2,
name: '大脸怪(editor)',
avatar: 'https://assets.qszone.com/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['editor'],
},
{
id: 3,
name: '访客(guest)',
avatar: 'https://assets.qszone.com/images/avatar.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>
<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 @@
<template> <template>
<header class="header"> <header class="header">
<BreadCrumb /> <div class="h-left">
<HeaderAction /> <MenuCollapse />
<BreadCrumb ml-15 />
</div>
<div class="h-right">
<GithubSite />
<FullScreen />
<UserAvatar />
</div>
</header> </header>
</template> </template>
<script setup> <script setup>
import BreadCrumb from './BreadCrumb.vue' import BreadCrumb from './components/BreadCrumb.vue'
import HeaderAction from './HeaderAction.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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.header { .header {
padding: 0 24px; padding: 0 15px;
height: 100%; height: 100%;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
.h-left {
display: flex;
align-items: center;
}
.h-right {
display: flex;
align-items: center;
}
} }
</style> </style>

View File

@@ -1,31 +0,0 @@
<template>
<div class="logo">
<n-icon size="36" color="#316c72">
<IconLogo />
</n-icon>
<router-link to="/">
<n-gradient-text type="primary">{{ title }}</n-gradient-text>
</router-link>
</div>
</template>
<script setup>
import { IconLogo } from '@/components/AppIcons'
const title = import.meta.env.VITE_APP_TITLE
</script>
<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,134 +0,0 @@
<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>
<script setup>
import { useRouter } from 'vue-router'
import { computed, h } from 'vue'
import { usePermissionStore } from '@/store/modules/permission'
import { NIcon } from 'naive-ui'
import { IconCircle, IconMenu } from '@/components/AppIcons'
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 || IconMenu, { size: 16 })
curOption.children = generateOptions(route.children, resolvePath(basePath, route.path))
} else {
curOption.icon = (route.meta?.icon && renderIcon(route.meta?.icon)) || renderIcon(IconCircle, { 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>
<style lang="scss">
.n-menu {
margin-top: 10px;
padding-left: 10px;
.n-menu-item-content {
&::before {
left: 0;
right: 0;
border-radius: 0;
background-color: unset !important;
}
&:hover,
&.n-menu-item-content--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,6 +1,6 @@
<script setup> <script setup>
import SideLogo from './SideLogo.vue' import SideLogo from './components/SideLogo.vue'
import SideMenu from './SideMenu.vue' import SideMenu from './components/SideMenu.vue'
</script> </script>
<template> <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,32 +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> <template>
<div class="layout"> <n-layout has-sider style="height: 100%">
<n-layout has-sider position="absolute"> <n-layout-sider
<n-layout-sider :width="200" :collapsed-width="0" :native-scrollbar="false"> bordered
<SideMenu /> collapse-mode="width"
</n-layout-sider> :collapsed-width="64"
<n-layout> :width="220"
<n-layout-header> :native-scrollbar="false"
<AppHeader /> :collapsed="appStore.collapsed"
</n-layout-header> >
<n-layout position="absolute" style="top: 60px; background-color: #f5f6fb" :native-scrollbar="false"> <SideBar />
<AppMain /> </n-layout-sider>
</n-layout> <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>
</n-layout> </n-layout>
</div> </n-layout>
</template> </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> <style lang="scss" scoped>
.n-layout-header { .n-layout-header {
height: 60px; height: 60px;
background-color: #fff; background-color: #fff;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
border-left: 1px solid #eee;
} }
</style> </style>

View File

@@ -1,3 +1,5 @@
import '@/styles/reset.css'
import '@/styles/variables.css'
import '@/styles/index.scss' import '@/styles/index.scss'
import 'uno.css' import 'uno.css'

View File

@@ -2,6 +2,7 @@ import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' import { usePermissionStore } from '@/store/modules/permission'
import { NOT_FOUND_ROUTE } from '@/router/routes' import { NOT_FOUND_ROUTE } from '@/router/routes'
import { getToken, refreshAccessToken, removeToken } from '@/utils/token' import { getToken, refreshAccessToken, removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
const WHITE_LIST = ['/login', '/redirect'] const WHITE_LIST = ['/login', '/redirect']
export function createPermissionGuard(router) { export function createPermissionGuard(router) {
@@ -18,19 +19,18 @@ export function createPermissionGuard(router) {
refreshAccessToken() refreshAccessToken()
next() next()
} else { } else {
try { await userStore.getUserInfo().catch((error) => {
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) {
removeToken() removeToken()
$message.error(error) toLogin()
next({ path: '/login', query: { ...to.query, redirect: to.path } }) $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 { } 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 { setupRouterGuard } from './guard'
import { basicRoutes } from './routes' import { basicRoutes } from './routes'
const isHash = !!import.meta.env.VITE_APP_USE_HASH
export const router = createRouter({ export const router = createRouter({
history: createWebHistory('/'), history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
routes: basicRoutes, routes: [],
scrollBehavior: () => ({ left: 0, top: 0 }), scrollBehavior: () => ({ left: 0, top: 0 }),
}) })
@@ -19,6 +20,9 @@ export function resetRouter() {
} }
export function setupRouter(app) { export function setupRouter(app) {
basicRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
app.use(router) app.use(router)
setupRouterGuard(router) setupRouterGuard(router)
} }

View File

@@ -1,8 +1,6 @@
import Layout from '@/layout/index.vue' import Layout from '@/layout/index.vue'
import Home from '@/views/dashboard/index.vue' import Home from '@/views/dashboard/index.vue'
import { IconAlert, IconChart, IconGitee, IconGithub, IconHome, IconLink, IconVue } from '@/components/AppIcons'
export const basicRoutes = [ export const basicRoutes = [
{ {
name: '404', name: '404',
@@ -34,64 +32,88 @@ export const basicRoutes = [
}, },
{ {
name: 'DASHBOARD', name: 'Dashboard',
path: '/', path: '/',
component: Layout, component: Layout,
redirect: '/home', redirect: '/home',
meta: { meta: {
title: 'Dashboard', title: 'Dashboard',
icon: IconChart, icon: 'mdi:chart-bar',
}, },
children: [ children: [
{ {
name: 'HOME', name: 'Home',
path: 'home', path: 'home',
component: Home, component: Home,
meta: { meta: {
title: '首页', title: '首页',
icon: IconHome, 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', path: '/test',
component: Layout, component: Layout,
redirect: '/test/unocss', redirect: '/test/unocss',
meta: { meta: {
title: '基础功能测试', title: '基础功能测试',
icon: 'mdi:menu',
}, },
children: [ children: [
{ {
name: 'UNOCSS', name: 'Unocss',
path: 'unocss', path: 'unocss',
component: () => import('@/views/test-page/TestUnocss.vue'), component: () => import('@/views/test-page/unocss/index.vue'),
meta: { meta: {
title: '测试unocss', title: '测试unocss',
}, },
}, },
{ {
name: 'MESSAGE', name: 'Message',
path: 'message', path: 'message',
component: () => import('@/views/test-page/TestMessage.vue'), component: () => import('@/views/test-page/message/index.vue'),
meta: { meta: {
title: '测试Message', title: '测试Message',
}, },
}, },
{ {
name: 'DIALOG', name: 'Dialog',
path: 'dialog', path: 'dialog',
component: () => import('@/views/test-page/TestDialog.vue'), component: () => import('@/views/test-page/dialog/index.vue'),
meta: { meta: {
title: '测试Dialog', title: '测试Dialog',
}, },
}, },
{ {
name: 'TEST-KEEP-ALIVE', name: 'TestKeepAlive',
path: 'keep-alive', path: 'keep-alive',
component: () => import('@/views/test-page/TestKeepAlive.vue'), component: () => import('@/views/test-page/keep-alive/index.vue'),
meta: { meta: {
title: '测试Keep-Alive', title: '测试Keep-Alive',
keepAlive: true, keepAlive: true,
@@ -101,57 +123,36 @@ export const basicRoutes = [
}, },
{ {
name: 'ERROR-PAGE', name: 'ExternalLink',
path: '/error-page',
component: Layout,
redirect: '/error-page/404',
meta: {
title: '错误页',
icon: IconAlert,
},
children: [
{
name: 'ERROR-404',
path: '404',
component: () => import('@/views/error-page/404.vue'),
meta: {
title: '404',
},
},
],
},
{
name: 'EXTERNAL-LINK',
path: '/external-link', path: '/external-link',
component: Layout, component: Layout,
meta: { meta: {
title: '外部链接', title: '外部链接',
icon: IconLink, icon: 'mdi:link-variant',
}, },
children: [ children: [
{ {
name: 'LINK-GITHUB-SRC', name: 'LinkGithubSrc',
path: 'https://github.com/zclzone/vue-naive-admin', path: 'https://github.com/zclzone/vue-naive-admin',
meta: { meta: {
title: '源码 - github', title: '源码 - github',
icon: IconGithub, icon: 'mdi:github',
}, },
}, },
{ {
name: 'LINK-GITEE-SRC', name: 'LinkGiteeSrc',
path: 'https://gitee.com/zclzone/vue-naive-admin', path: 'https://gitee.com/zclzone/vue-naive-admin',
meta: { meta: {
title: '源码 - gitee', title: '源码 - gitee',
icon: IconGitee, icon: 'simple-icons:gitee',
}, },
}, },
{ {
name: 'LINK-DOCS', name: 'LinkDocs',
path: 'https://zclzone.github.io/vue-naive-admin-docs', path: 'https://zclzone.github.io/vue-naive-admin-docs',
meta: { meta: {
title: '文档 - vuepress', title: '文档 - vuepress',
icon: IconVue, icon: 'mdi:vuejs',
}, },
}, },
], ],
@@ -159,7 +160,7 @@ export const basicRoutes = [
] ]
export const NOT_FOUND_ROUTE = { export const NOT_FOUND_ROUTE = {
name: 'NOT_FOUND', name: 'NotFound',
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
redirect: '/404', redirect: '/404',
isHidden: true, isHidden: true,

View File

@@ -2,7 +2,7 @@ import Layout from '@/layout/index.vue'
export default [ export default [
{ {
name: 'EXAMPLE', name: 'Example',
path: '/example', path: '/example',
component: Layout, component: Layout,
redirect: '/example/table', redirect: '/example/table',
@@ -12,17 +12,18 @@ export default [
}, },
children: [ children: [
{ {
name: 'EXAMPLE-TABLE', name: 'Table',
path: 'table', path: 'table',
component: () => import('@/views/examples/table/index.vue'), component: () => import('@/views/examples/table/index.vue'),
redirect: '/example/table/post', redirect: '/example/table/post',
meta: { meta: {
title: '表格', title: '表格',
role: ['admin'], role: ['admin'],
icon: 'mdi:table',
}, },
children: [ children: [
{ {
name: 'POST-LIST', name: 'PostList',
path: 'post', path: 'post',
component: () => import('@/views/examples/table/post/index.vue'), component: () => import('@/views/examples/table/post/index.vue'),
meta: { meta: {
@@ -31,9 +32,9 @@ export default [
}, },
}, },
{ {
name: 'POST-CREATE', name: 'PostCreate',
path: 'post-create', path: 'post-create',
component: () => import('@/views/examples/table/post/post-create.vue'), component: () => import('@/views/examples/table/post/PostCreate.vue'),
meta: { meta: {
title: '创建文章', title: '创建文章',
role: ['admin'], 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', { export const useAppStore = defineStore('app', {
state() { state() {
return { return {
themeOverrides: { reloadFlag: true,
common: { collapsed: false,
primaryColor: '#316c72',
primaryColorSuppl: '#316c72',
primaryColorHover: '#316c72',
successColorHover: '#316c72',
successColorSuppl: '#316c72',
},
},
} }
}, },
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() { routes() {
return basicRoutes.concat(this.accessRoutes) return basicRoutes.concat(this.accessRoutes)
}, },
menus() {
return this.routes.filter((route) => route.name && !route.isHidden)
},
}, },
actions: { actions: {
generateRoutes(role = []) { 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 { defineStore } from 'pinia'
import { getUser } from '@/api/user' import { getUser } from '@/api/user'
import { removeToken } from '@/utils/token' import { removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'
export const useUserStore = defineStore('user', { export const useUserStore = defineStore('user', {
state() { state() {
@@ -31,16 +32,16 @@ export const useUserStore = defineStore('user', {
this.userInfo = { id, name, avatar, role } this.userInfo = { id, name, avatar, role }
return Promise.resolve(res.data) return Promise.resolve(res.data)
} else { } else {
return Promise.reject(res.message) return Promise.reject(res)
} }
} catch (error) { } catch (error) {
console.error(error) return Promise.reject(error)
return Promise.reject(error.message)
} }
}, },
logout() { async logout() {
removeToken() removeToken()
this.userInfo = {} this.userInfo = {}
toLogin()
}, },
setUserInfo(userInfo = {}) { setUserInfo(userInfo = {}) {
this.userInfo = { ...this.userInfo, ...userInfo } this.userInfo = { ...this.userInfo, ...userInfo }

View File

@@ -1,2 +1,54 @@
@import './reset.scss'; html {
@import './public.scss'; 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 './web-storage' import { createWebStorage } from './web-storage'
export const createLocalStorage = function (option = {}) { export const createLocalStorage = function (option = {}) {
return createWebStorage({ prefixKey: option.prefixKey || '', storage: localStorage }) return createWebStorage({
prefixKey: option.prefixKey || '',
storage: localStorage,
})
} }
export const createSessionStorage = function (option = {}) { 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 axios from 'axios'
import { setupInterceptor } from './interceptors' import { repReject, repResolve, reqReject, reqResolve } from './interceptors'
function createAxios(option = {}) { export function createAxios(options = {}) {
const defBaseURL = window.__APP__GLOB__CONF__?.VITE_APP_GLOB_BASE_API || import.meta.env.VITE_APP_GLOB_BASE_API const defaultOptions = {
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 12000,
}
const service = axios.create({ const service = axios.create({
timeout: option.timeout || 120000, ...defaultOptions,
baseURL: option.baseURL || defBaseURL, ...options,
}) })
setupInterceptor(service) service.interceptors.request.use(reqResolve, reqReject)
service.interceptors.response.use(repResolve, repReject)
return service return service
} }
export const defAxios = createAxios() export const defAxios = createAxios()
export const testAxios = 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 } from '@/utils/token'
import { getToken, removeToken } from '@/utils/token' import { toLogin } from '@/utils/auth'
import { isWithoutToken } from './help' import { isNullOrUndef } from '@/utils/is'
import { isWithoutToken } from './helpers'
export function setupInterceptor(service) { export function reqResolve(config) {
service.interceptors.request.use( // 防止缓存给get请求加上时间戳
async (config) => { if (config.method === 'get') {
// 防止缓存给get请求加上时间戳 config.params = { ...config.params, t: new Date().getTime() }
if (config.method === 'get') { }
config.params = { ...config.params, t: new Date().getTime() }
}
// 处理不需要token的请求 // 处理不需要token的请求
if (isWithoutToken(config)) { if (isWithoutToken(config)) {
return config return config
} }
const token = getToken() const token = getToken()
if (token) { if (!token) {
/** /**
* * jwt token * * 未登录或者token过期的情况下
* ! 认证方案: Bearer * * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面
*/ */
config.headers.Authorization = 'Bearer ' + token toLogin()
return Promise.reject({ code: '-1', message: '未登录' })
}
return config /**
} * * jwt token
/** * ! 认证方案: Bearer
* * 未登录或者token过期的情况下 */
* * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面 config.headers.Authorization = config.headers.Authorization || 'Bearer ' + token
*/
const { currentRoute } = router
router.replace({
path: '/login',
query: { ...currentRoute.query, redirect: currentRoute.path },
})
return Promise.reject({ code: '-1', message: '未登录' })
},
(error) => Promise.reject(error)
)
service.interceptors.response.use( return config
(response) => response?.data, }
(error) => {
let { code, message } = error.response?.data export function reqReject(error) {
return Promise.reject({ code, message }) return Promise.reject(error)
}
/**
* TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理 export function repResolve(response) {
*/ return response?.data
switch (code) { }
case 401:
// 未登录可能是token过期或者无效了 export function repReject(error) {
console.error(message) let { code, message } = error.response?.data || {}
removeToken() if (isNullOrUndef(code)) {
const { currentRoute } = router // 未知错误
router.replace({ code = -1
path: '/login', message = '接口异常!'
query: { ...currentRoute.query, redirect: currentRoute.path }, } else {
}) /**
break * TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
case 403: */
// 没有权限 switch (code) {
console.error(message) case 401:
break message = message || '登录已过期'
case 404: break
// 资源不存在 case 403:
console.error(message) message = message || '没有权限'
break break
default: case 404:
break message = message || '资源或接口不存在'
} break
// 已知错误resolve在业务代码中作提醒未知错误reject捕获错误统一提示接口异常9000以上为业务类型错误需要跟后端确定好 default:
if ([401, 403, 404].includes(code) || code >= 9000) { message = message || '未知异常'
return Promise.resolve({ code, message }) break
} else { }
console.error('【err】' + error) }
return Promise.reject({ message: '接口异常,请稍后重试!' }) 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 return val === null
} }
export function isWhitespace(val) {
return val === ''
}
export function isObject(val) { export function isObject(val) {
return !isNull(val) && is(val, 'Object') return !isNull(val) && is(val, 'Object')
} }
@@ -64,6 +68,10 @@ export function isNullOrUndef(val) {
return isNull(val) || isUndef(val) return isNull(val) || isUndef(val)
} }
export function isNullOrWhitespace(val) {
return isNullOrUndef(val) || isWhitespace(val)
}
export function isEmpty(val) { export function isEmpty(val) {
if (isArray(val) || isString(val)) { if (isArray(val) || isString(val)) {
return val.length === 0 return val.length === 0
@@ -81,14 +89,14 @@ export function isEmpty(val) {
} }
/** /**
* * 类似sql的isnull函数 * * 类似mysql的IFNULL函数
* * 第一个参数为null/undefined/''则返回第二个参数作为默认值,否则返回第一个参数 * * 第一个参数为null/undefined/'' 则返回第二个参数作为备用值,否则返回第一个参数
* @param {Number|Boolean|String} val * @param {Number|Boolean|String} val
* @param {Number|Boolean|String} replaceVal * @param {Number|Boolean|String} def
* @returns * @returns
*/ */
export function isNullReplace(val, replaceVal = '') { export function ifNull(val, def = '') {
return isNullOrUndef(val) || val === '' ? replaceVal : val return isNullOrWhitespace(val) ? def : val
} }
export function isUrl(path) { export function isUrl(path) {

View File

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

View File

@@ -1,42 +1,51 @@
<template> <template>
<div> <div p-15>
<n-card> <n-card rounded-10>
<div flex items-center> <div flex items-center>
<img width="60" style="border-radius: 50%" :src="userStore.avatar" /> <img rounded-full width="60" :src="userStore.avatar" />
<div ml20> <div ml-20>
<p text-16>Hello, {{ userStore.name }}</p> <p text-16>Hello, {{ userStore.name }}</p>
<p op80 text-12 mt5>今天又是元气满满的一天</p> <p mt-5 text-12 op-60>今天又是元气满满的一天</p>
</div> </div>
<div flex ml-auto> <div ml-auto flex items-center>
<n-statistic label="待办" :value="4"> <n-statistic label="待办" :value="4">
<template #suffix> / 10 </template> <template #suffix> / 10 </template>
</n-statistic> </n-statistic>
<n-statistic ml80 label="Stars"> <n-statistic label="Stars" w-100 ml-80>
<n-number-animation ref="starsNumberRef" show-separator :from="0" :to="999" /> <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>
<n-statistic ml80 label="Forks"> <n-statistic label="Forks" w-100 ml-80>
<n-number-animation ref="starsNumberRef" show-separator :from="0" :to="299" /> <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> </n-statistic>
</div> </div>
</div> </div>
</n-card> </n-card>
<div p15 flex> <n-card title="项目" size="small" :segmented="true" mt-15 rounded-10>
<n-card title="项目" size="small" :segmented="true"> <template #header-extra>
<template #header-extra> <n-button text type="primary">更多</n-button>
<n-button text type="primary">更多</n-button> </template>
</template> <div flex flex-wrap justify-between>
<div class="card-list"> <n-card
<n-card v-for="i in 10" :key="i" title="Vue Naive Admin" size="small"> v-for="i in 10"
<p op60>一个基于 Vue3.0ViteNaive UI 的轻量级后台管理模板</p> :key="i"
</n-card> class="w-300 flex-shrink-0 mt-10 mb-10 cursor-pointer"
<div class="blank"></div> hover:card-shadow
<div class="blank"></div> title="Vue Naive Admin"
<div class="blank"></div> size="small"
<div class="blank"></div> >
</div> <p op-60>一个基于 Vue3.0ViteNaive UI 的轻量级后台管理模板</p>
</n-card> </n-card>
</div> <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> </div>
</template> </template>
@@ -45,24 +54,3 @@ import { useUserStore } from '@/store/modules/user'
const userStore = useUserStore() const userStore = useUserStore()
</script> </script>
<style lang="scss" scoped>
.card-list {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.n-card {
width: 300px;
flex-shrink: 0;
margin: 10px 0;
cursor: pointer;
&:hover {
box-shadow: 0 1px 2px -2px #00000029, 0 3px 6px #0000001f, 0 5px 12px 4px #00000017;
}
}
.blank {
width: 300px;
height: 0;
}
}
</style>

View File

@@ -12,14 +12,12 @@
</template> </template>
<script setup> <script setup>
import { useRouter } from 'vue-router'
const { replace } = useRouter() const { replace } = useRouter()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page-404 { .page-404 {
height: 100%; height: 100%;
min-height: calc(100vh - 60px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -1,17 +1,14 @@
<template> <template>
<div> <div p-20>
<div class="header"> <div class="header">
<input v-model="post.title" type="text" placeholder="输入文章标题..." class="title" /> <input v-model="post.title" type="text" placeholder="输入文章标题..." class="title" />
<n-button type="primary" style="width: 80px" :loading="btnLoading" @click="handleSavePost">保存</n-button> <n-button type="primary" style="width: 80px" :loading="btnLoading" @click="handleSavePost">保存</n-button>
</div> </div>
<MdEditor v-model="post.content" style="height: calc(100vh - 140px)" /> <MdEditor v-model="post.content" style="height: calc(100vh - 210px)" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import MdEditor from 'md-editor-v3' import MdEditor from 'md-editor-v3'
import 'md-editor-v3/lib/style.css' import 'md-editor-v3/lib/style.css'
@@ -54,7 +51,7 @@ function handleSavePost(e) {
margin-right: 20px; margin-right: 20px;
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
color: $primaryColor; color: var(--primaryColor);
} }
} }
</style> </style>

View File

@@ -11,84 +11,31 @@
:columns="columns" :columns="columns"
:pagination="pagination" :pagination="pagination"
:row-key="(row) => row.id" :row-key="(row) => row.id"
max-height="calc(100vh - 250px)"
@update:checked-row-keys="handleCheck" @update:checked-row-keys="handleCheck"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onBeforeMount } from 'vue' import { usePostTable } from './usePostTable'
import { useRouter } from 'vue-router'
import { getTableData, createColumns } from './post-table'
const router = useRouter() const router = useRouter()
// 静态变量 const pagination = ref({ pageSize: 10 })
const columns = createColumns({ const { loading, columns, tableData, initColumns, initTableData } = usePostTable()
handleDelete,
handleRecommend,
handlePublish,
})
// refs
let tableData = ref([])
let pagination = ref({ pageSize: 10 })
let loading = ref(false)
// 钩子函数
onBeforeMount(() => { onBeforeMount(() => {
initColumns()
initTableData() initTableData()
}) })
// fns
async function initTableData() {
loading.value = true
tableData.value = await getTableData()
loading.value = false
}
function handleCreate() { function handleCreate() {
router.push('/example/table/post-create') router.push('/example/table/post-create')
} }
function handleDelete(row) { function handleCheck(rowKeys) {
if (row && row.id) { if (rowKeys.length) $message.info(`选中${rowKeys.join(' ')}`)
$dialog.confirm({
content: '确定删除?',
confirm() {
$message.success('删除成功')
initTableData()
},
cancel() {
$message.success('已取消')
},
})
}
} }
async function handleRecommend(row) {
if (row && row.id) {
row.recommending = true
setTimeout(() => {
$message.success(row.isRecommend ? '已取消推荐' : '已推荐')
row.recommending = false
}, 800)
}
}
async function handlePublish(row) {
if (row && row.id) {
row.publishing = true
setTimeout(() => {
$message.success(row.isPublish ? '已取消推荐' : '已推荐')
row.publishing = false
}, 800)
}
}
function handleCheck(rowKeys) {}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,99 +0,0 @@
import { h } from 'vue'
import { NButton, NSwitch } from 'naive-ui'
import { getPosts } from '@/api/post'
import { formatDateTime } from '@/utils'
export async function getTableData() {
try {
const res = await getPosts()
if (res.code === 0) {
return res.data
}
console.warn(res.message)
return []
} catch (error) {
console.error(error)
return []
}
}
export function createColumns({ handleDelete, handleRecommend, handlePublish }) {
return [
{ type: 'selection' },
{ title: '标题', key: 'title', width: 150 },
{ title: '分类', key: 'category', width: 80 },
{
title: '描述',
key: 'description',
width: 200,
},
{ title: '创建人', key: 'author', width: 80 },
{
title: '创建时间',
key: 'createDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['createDate']))
},
},
{
title: '最后更新时间',
key: 'updateDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['updateDate']))
},
},
{
title: '推荐',
key: 'isRecommend',
width: 100,
align: 'center',
fixed: 'right',
render(row) {
return h(NSwitch, {
size: 'small',
defaultValue: row['isRecommend'],
loading: !!row.recommending,
onUpdateValue: () => handleRecommend(row),
})
},
},
{
title: '发布',
key: 'isPublish',
width: 100,
align: 'center',
fixed: 'right',
render(row) {
return h(NSwitch, {
size: 'small',
defaultValue: row['isPublish'],
loading: !!row.publishing,
onUpdateValue: () => handlePublish(row),
})
},
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
fixed: 'right',
render(row) {
return [
h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px;',
onClick: () => handleDelete(row),
},
{ default: () => '删除' }
),
]
},
},
]
}

View File

@@ -0,0 +1,155 @@
import { h } from 'vue'
import { NButton, NSwitch } from 'naive-ui'
import { getPosts } from '@/api/post'
import { formatDateTime } from '@/utils'
export const usePostTable = () => {
// refs
const loading = ref(false)
const tableData = ref([])
const columns = ref([])
async function initTableData() {
loading.value = true
tableData.value = await getTableData()
loading.value = false
}
function handleDelete(row) {
if (row && row.id) {
$dialog.confirm({
content: '确定删除?',
confirm() {
$message.success('删除成功')
initTableData()
},
cancel() {
$message.success('已取消')
},
})
}
}
async function handleRecommend(row) {
if (row && row.id) {
row.recommending = true
setTimeout(() => {
$message.success(row.isRecommend ? '已取消推荐' : '已推荐')
row.recommending = false
}, 800)
}
}
async function handlePublish(row) {
if (row && row.id) {
row.publishing = true
setTimeout(() => {
$message.success(row.isPublish ? '已取消推荐' : '已推荐')
row.publishing = false
}, 800)
}
}
function initColumns() {
columns.value = [
{ type: 'selection' },
{ title: '标题', key: 'title', width: 150 },
{ title: '分类', key: 'category', width: 80 },
{
title: '描述',
key: 'description',
width: 200,
},
{ title: '创建人', key: 'author', width: 80 },
{
title: '创建时间',
key: 'createDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['createDate']))
},
},
{
title: '最后更新时间',
key: 'updateDate',
width: 150,
render(row) {
return h('span', formatDateTime(row['updateDate']))
},
},
{
title: '推荐',
key: 'isRecommend',
width: 100,
align: 'center',
fixed: 'right',
render(row) {
return h(NSwitch, {
size: 'small',
defaultValue: row['isRecommend'],
loading: !!row.recommending,
onUpdateValue: () => handleRecommend(row),
})
},
},
{
title: '发布',
key: 'isPublish',
width: 100,
align: 'center',
fixed: 'right',
render(row) {
return h(NSwitch, {
size: 'small',
defaultValue: row['isPublish'],
loading: !!row.publishing,
onUpdateValue: () => handlePublish(row),
})
},
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
fixed: 'right',
render(row) {
return [
h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px;',
onClick: () => handleDelete(row),
},
{ default: () => '删除' }
),
]
},
},
]
}
async function getTableData() {
try {
const res = await getPosts()
if (res.code === 0) {
return res.data
}
console.warn(res.message)
return []
} catch (error) {
console.error(error)
return []
}
}
return {
loading,
columns,
tableData,
initColumns,
initTableData,
}
}

View File

@@ -1,71 +1,45 @@
<template> <template>
<div class="login-page"> <div class="login-bg" f-c-c h-full>
<div class="wrapper"> <div class="login-wrapper" flex w-full max-w-1020>
<div class="left"> <div p-40 border-r border-gray-200>
<img src="@/assets/images/login_banner.png" height="380" alt="login_banner" /> <img src="@/assets/images/login_banner.png" height="380" alt="login_banner" />
</div> </div>
<div class="form-wrapper"> <div w-full f-c-c flex-col>
<h5 class="brand"> <h5 f-c-c w-full p-15 text-24 font-normal color="#6a6a6a">
<img src="@/assets/images/logo.svg" width="45" mr-15 alt="logo" /> <icon-custom-logo mr30 text-50 />
{{ title }} {{ title }}
</h5> </h5>
<div class="form-item" mt-35> <div mt-35 w-full max-w-360>
<input <n-input
v-model="loginInfo.name" v-model:value="loginInfo.name"
autofocus autofocus
type="text" class="text-16 items-center h-50 pl-10"
class="input" placeholder="请输入用户名"
placeholder="username" :maxlength="20"
@keydown.enter="handleLogin" >
/> </n-input>
</div> </div>
<div class="form-item" mt-35> <div mt-35 w-full max-w-360>
<input <n-input
v-model="loginInfo.password" v-model:value="loginInfo.password"
class="text-16 items-center h-50 pl-10"
type="password" type="password"
class="input" show-password-on="mousedown"
placeholder="password" placeholder="密码"
:maxlength="20"
@keydown.enter="handleLogin" @keydown.enter="handleLogin"
/> />
</div> </div>
<div class="form-item" mt-35> <div mt-35 w-full max-w-360>
<button class="submit-btn" @click="handleLogin">登录</button> <n-button w-full h-50 rounded-5 text-16 type="primary" @click="handleLogin">登录</n-button>
</div> </div>
</div> </div>
</div> </div>
<!-- <div class="form-wrapper">
<h2 class="title">{{ title }}</h2>
<div class="form-item" mt-20>
<input
v-model="loginInfo.name"
autofocus
type="text"
class="input"
placeholder="username"
@keydown.enter="handleLogin"
/>
</div>
<div class="form-item" mt-20>
<input
v-model="loginInfo.password"
type="password"
class="input"
placeholder="password"
@keydown.enter="handleLogin"
/>
</div>
<div class="form-item" mt-20>
<button class="submit-btn" @click="handleLogin">登录</button>
</div>
</div> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, unref } from 'vue'
import { useRouter } from 'vue-router'
import { login } from '@/api/auth' import { login } from '@/api/auth'
import { createLocalStorage } from '@/utils/cache' import { createLocalStorage } from '@/utils/cache'
import { setToken } from '@/utils/token' import { setToken } from '@/utils/token'
@@ -77,7 +51,7 @@ const query = unref(router.currentRoute).query
const loginInfo = ref({ const loginInfo = ref({
name: 'admin', name: 'admin',
password: 123456, password: '123456',
}) })
const ls = createLocalStorage({ prefixKey: 'login_' }) const ls = createLocalStorage({ prefixKey: 'login_' })
@@ -118,81 +92,13 @@ async function handleLogin() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.login-page { .login-bg {
height: 100%;
background-image: url(@/assets/images/login_bg.jpg); background-image: url(@/assets/images/login_bg.jpg);
background-size: 100%; background-size: cover;
display: flex;
align-items: center;
justify-content: center;
} }
.wrapper { .login-wrapper {
width: 100%;
max-width: 1020px;
box-shadow: 1.5px 3.99px 27px 0px rgb(0 0 0 / 10%); box-shadow: 1.5px 3.99px 27px 0px rgb(0 0 0 / 10%);
background-color: rgba(255, 255, 255, 0.3); background-color: rgba(255, 255, 255, 0.6);
display: flex;
.left {
padding: 40px;
border-right: 1px solid #cccccc5e;
}
}
.form-wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
.brand {
width: 100%;
padding: 15px;
color: #6a6a6a;
font-size: 24px;
font-weight: normal;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.form-item {
width: 100%;
max-width: 360px;
height: 50px;
input {
width: 100%;
height: 100%;
padding: 0 20px;
border: 1px solid #6a6a6a;
border-radius: 5px;
color: $primaryColor;
font-size: 16px;
transition: 0.5s;
&:focus {
border-color: $primaryColor;
box-shadow: 0 0 5px $primaryColor;
}
}
button {
width: 100%;
height: 100%;
color: #fff;
font-size: 16px;
font-weight: bold;
border: none;
border-radius: 5px;
background-color: $primaryColor;
cursor: pointer;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
}
} }
</style> </style>

View File

@@ -1,8 +1,6 @@
<template></template> <template></template>
<script setup> <script setup>
import { useRouter } from 'vue-router'
const { currentRoute, replace } = useRouter() const { currentRoute, replace } = useRouter()
const { query } = currentRoute.value const { query } = currentRoute.value

View File

@@ -1,15 +0,0 @@
<template>
<div p24>
<div p20 bg="#fff">
<p text-12>测试12px</p>
<p text-13>测试13px</p>
<p text-14>测试14px</p>
<p text-15>测试15px</p>
<p text-16>测试16px</p>
<p text-17>测试17px</p>
<p text-18>测试18px</p>
<p text-19>测试19px</p>
<p text-20>测试20px</p>
</div>
</div>
</template>

View File

@@ -9,10 +9,10 @@ const handleDelete = function () {
$dialog.confirm({ $dialog.confirm({
content: '确认删除?', content: '确认删除?',
confirm() { confirm() {
$dialog.success('删除成功', { positiveText: '我知道了' }) $message.success('删除成功')
}, },
cancel() { cancel() {
$dialog.warning('已取消', { closable: true }) $message.warning('已取消')
}, },
}) })
} }

View File

@@ -5,9 +5,7 @@
</template> </template>
<!--使用keep-alive须设置name注意请与对应的路由的name保持一致方便统一处理--> <!--使用keep-alive须设置name注意请与对应的路由的name保持一致方便统一处理-->
<script setup name="TEST-KEEP-ALIVE"> <script setup name="TestKeepAlive">
import { onMounted, onActivated, onDeactivated } from 'vue'
onMounted(() => { onMounted(() => {
$message.success('触发onMounted') $message.success('触发onMounted')
}) })

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