Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e26769679 | ||
|
|
b7ce7912a7 | ||
|
|
1fa9d4d472 | ||
|
|
fa11b1bc64 | ||
|
|
4bf8916fdc | ||
|
|
8496c08646 | ||
|
|
065868a40b | ||
|
|
dd4cd871ba | ||
|
|
a2b84d35f7 | ||
|
|
e128dfabc7 | ||
|
|
21c1d6d3aa | ||
|
|
3990d4da80 | ||
|
|
ac9ccbadf0 | ||
|
|
8ae4046285 | ||
|
|
f88b4f52a1 | ||
|
|
00ba77c15e | ||
|
|
39a80926bf | ||
|
|
16957a96b7 | ||
|
|
ef33b28492 | ||
|
|
ae43ffb94f | ||
|
|
f0b6ce7d20 | ||
|
|
c3354afa6c | ||
|
|
08ef914528 | ||
|
|
d86ee26ad6 | ||
|
|
c8495f7a5f | ||
|
|
0636ac4716 | ||
|
|
67d966e096 | ||
|
|
b5ac614943 | ||
|
|
9151b2d297 | ||
|
|
84f8431134 | ||
|
|
fdc49f6dcc | ||
|
|
d2b88a8300 | ||
|
|
ffc042167a | ||
|
|
85f9c91d6e | ||
|
|
21391b202f | ||
|
|
36ddb23db6 | ||
|
|
f3c391c031 | ||
|
|
df378f784b | ||
|
|
2154267615 | ||
|
|
3203a9a459 | ||
|
|
5ce2150706 | ||
|
|
5bd380037c | ||
|
|
e63e9f5cf2 | ||
|
|
74c244cf37 | ||
|
|
5cd85cf72d | ||
|
|
96d88a97f1 | ||
|
|
00c32a950a | ||
|
|
bfd048d40a | ||
|
|
1254a199d7 | ||
|
|
1190d08a87 | ||
|
|
958589edd0 | ||
|
|
2338ded165 | ||
|
|
4d6a58bfc8 | ||
|
|
f15e21b0a0 | ||
|
|
f88820b727 | ||
|
|
598d256be4 | ||
|
|
5b51cfb4f1 | ||
|
|
45c2e3aebe | ||
|
|
c2249d531f | ||
|
|
44c6b420d0 | ||
|
|
117a46a251 | ||
|
|
d8569a4eb1 | ||
|
|
c268b3c75d | ||
|
|
21e0d86fcd | ||
|
|
76bd414941 | ||
|
|
894b87426a | ||
|
|
f9c2362cd8 | ||
|
|
d922dcc224 | ||
|
|
7c8a17bbb2 | ||
|
|
321e19a3a5 | ||
|
|
bf2d45416f | ||
|
|
cf1b83d3f1 | ||
|
|
bf63fb5ab7 | ||
|
|
a6f86ee315 | ||
|
|
b2cf78b36d | ||
|
|
c9c0c35343 | ||
|
|
3c46d2c159 | ||
|
|
585bf4a4c4 | ||
|
|
2bd85e6e60 | ||
|
|
967ae1c483 | ||
|
|
238bceb500 | ||
|
|
c2145c0ddb | ||
|
|
d759c9b9ae | ||
|
|
3fdba613d3 | ||
|
|
40d5106c6b | ||
|
|
094a9dcb3b | ||
|
|
db5089d92e | ||
|
|
a41ccad2d0 | ||
|
|
8973e39566 | ||
|
|
8c1191ece2 | ||
|
|
b3aa8147b1 | ||
|
|
0d240f083a | ||
|
|
c180cf54a8 | ||
|
|
2541706ac3 | ||
|
|
44b935e8f6 | ||
|
|
ea1ce0601a | ||
|
|
2989ecf126 | ||
|
|
ce94bf38d1 | ||
|
|
13bc185926 | ||
|
|
51cfd3e2eb | ||
|
|
c22cb3b35c | ||
|
|
621a2304e7 | ||
|
|
729337cdc5 | ||
|
|
4ef58b612f | ||
|
|
ba5d32244f | ||
|
|
efc2a194a3 | ||
|
|
6160c2e664 | ||
|
|
fbd1e9a38a | ||
|
|
17928cbc57 | ||
|
|
e7fc403c77 | ||
|
|
3f7ed95fdb | ||
|
|
7478f193f9 | ||
|
|
ba49d94bf4 | ||
|
|
ea9851ccd3 | ||
|
|
ec55f33655 | ||
|
|
7a85c714cb | ||
|
|
7b90d7f8de | ||
|
|
16f580c96d | ||
|
|
f1329a46e4 | ||
|
|
ef6df57dc5 | ||
|
|
95e5cd7134 | ||
|
|
9db7aa50a1 | ||
|
|
a9997984d5 | ||
|
|
acb47a17b4 | ||
|
|
9c5f4eaa3d | ||
|
|
361fb52345 | ||
|
|
5993e8d7d0 | ||
|
|
8648f16ed8 | ||
|
|
33aaadba60 | ||
|
|
437d87f19e | ||
|
|
dfcc8c2158 | ||
|
|
51a583fc1e |
3
.env
@@ -1,6 +1,3 @@
|
||||
VITE_APP_TITLE = 'Vue Naive Admin'
|
||||
|
||||
VITE_PORT = 3100
|
||||
|
||||
# 打包时自动生成CNAME文件,用于配置github pages自定义域名,如不需要可注释或者直接删除
|
||||
VITE_APP_GLOB_CNAME = 'template.qszone.com'
|
||||
@@ -8,7 +8,7 @@ VITE_APP_USE_MOCK = true
|
||||
VITE_PROXY = [["/api","http://localhost:8080"],["/api-test","localhost:8080"]]
|
||||
|
||||
# base api
|
||||
VITE_APP_GLOB_BASE_API = '/api'
|
||||
VITE_APP_BASE_API = '/api'
|
||||
|
||||
# test base api
|
||||
VITE_APP_GLOB_BASE_API_TEST = '/api-test'
|
||||
VITE_APP_BASE_API_TEST = '/api-test'
|
||||
16
.env.github
Normal file
@@ -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'
|
||||
@@ -5,7 +5,7 @@ VITE_PUBLIC_PATH = '/'
|
||||
VITE_APP_USE_MOCK = true
|
||||
|
||||
# base api
|
||||
VITE_APP_GLOB_BASE_API = '/api'
|
||||
VITE_APP_BASE_API = '/api'
|
||||
|
||||
# test base api
|
||||
VITE_APP_GLOB_BASE_API_TEST = '/api-test'
|
||||
VITE_APP_BASE_API_TEST = '/api-test'
|
||||
11
.env.staging
@@ -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'
|
||||
@@ -4,7 +4,7 @@ VITE_PUBLIC_PATH = '/'
|
||||
VITE_APP_USE_MOCK = true
|
||||
|
||||
# base api
|
||||
VITE_APP_GLOB_BASE_API = '/api'
|
||||
VITE_APP_BASE_API = '/api'
|
||||
|
||||
# test base api
|
||||
VITE_APP_GLOB_BASE_API_TEST = '/api-test'
|
||||
VITE_APP_BASE_API_TEST = '/api-test'
|
||||
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
public
|
||||
package.json
|
||||
13
.eslintrc.js
@@ -1,19 +1,8 @@
|
||||
// * https://zhuanlan.zhihu.com/p/388703150
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true, // browser global variables
|
||||
node: true,
|
||||
es2021: true, // adds all ECMAScript 2021 globals and automatically sets the ecmaVersion parser option to 12.
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
parser: 'vue-eslint-parser',
|
||||
extends: ['plugin:vue/vue3-recommended', 'plugin:prettier/recommended'],
|
||||
plugins: ['prettier'],
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'prettier/prettier': 'warn',
|
||||
'vue/valid-template-root': 'off',
|
||||
'vue/no-multiple-template-root': 'off',
|
||||
'vue/multi-word-component-names': [
|
||||
|
||||
38
.github/workflows/deploy.yml
vendored
Normal file
@@ -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
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
stats.html
|
||||
|
||||
10
.vscode/extensions.json
vendored
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"johnsoncodehk.volar",
|
||||
"hollowtree.vue-snippets",
|
||||
"esbenp.prettier-vscode",
|
||||
"vue.volar",
|
||||
"antfu.iconify",
|
||||
"mikestead.dotenv",
|
||||
"wayou.vscode-todo-highlight",
|
||||
"aaron-bond.better-comments"
|
||||
"sdras.vue-vscode-snippets",
|
||||
"cipchk.cssrem",
|
||||
]
|
||||
}
|
||||
|
||||
20
.vscode/settings.json
vendored
@@ -3,24 +3,10 @@
|
||||
"path-intellisense.mappings": {
|
||||
"@/": "${workspaceRoot}/src"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript"]
|
||||
"cssrem.rootFontSize": 4, // 适配unocss,1rem = 4px ==> 0.25rem = 1px
|
||||
}
|
||||
|
||||
21
LICENSE
Normal 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.
|
||||
73
README.md
@@ -1,37 +1,53 @@
|
||||
# VUE NAIVE ADMIN
|
||||
<p align="center">
|
||||
<a href="https://github.com/zclzone/vue-naive-admin">
|
||||
<img alt="Vue Naive Admin Logo" width="200" src="https://assets.qszone.com/images/logo_qs.svg">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/zclzone/vue-naive-admin/actions"><img allt="checks" src="https://badgen.net/github/checks/zclzone/vue-naive-admin"/></a>
|
||||
<a href="https://github.com/zclzone/vue-naive-admin"><img allt="stars" src="https://badgen.net/github/stars/zclzone/vue-naive-admin"/></a>
|
||||
<a href="https://github.com/zclzone/vue-naive-admin"><img allt="forks" src="https://badgen.net/github/forks/zclzone/vue-naive-admin"/></a>
|
||||
<a href="./LICENSE"><img allt="MIT License" src="https://badgen.net/github/license/zclzone/vue-naive-admin"/></a>
|
||||
</p>
|
||||
|
||||
## 简介
|
||||
|
||||
Vue Naive Admin,一个基于 Vue3.0、Vite、Naive UI 的轻量级后台管理模板,没有集成 TypeScript,没有集成国际化,没有集成复杂的主题配置,上手成本非常低,对新手极其友好。不过麻雀虽小五脏俱全,权限、Mock、菜单、axios 封装、pinia、项目配置、样式配置、环境配置,以及一些经常用的基础组件封装等等这些该有的都有,参考多个 vue3 后台管理模板后以最简洁优雅的方式实现,非常适用于中小型项目或者个人项目。
|
||||
### 简介
|
||||
|
||||
## 为什么要开发这个模板
|
||||
[Vue Naive Admin](https://github.com/zclzone/vue-naive-admin),一个基于 Vue3.0、Vite、Naive UI 的后台管理模板,相较于其他比较流行的后台管理模板,此项目相对简洁、轻量,学习成本非常低,对新手极其友好。不过麻雀虽小五脏俱全,权限、Mock、菜单、axios 封装、pinia、项目配置、样式配置、环境配置,以及一些经常用的基础组件封装等等这些该有的都有,非常适用于中小型项目或者个人项目,也可此模板进行二次封装改造用于大型项目。
|
||||
|
||||
1. Vue3 和 Vite 已经趋于成熟,学习 vite 和 vue3 非常有必要,通过开发模板进行学习是一个很好的方式,事实也证明我确实从中获益良多
|
||||
2. 目前主流的 Vue3+Vite 后台管理模板都相对复杂,甚至感觉有点花里胡哨(没有贬低的意思,大部分的架构设计都很优秀,只是觉得集成了太多不实用的东西)
|
||||
3. 自己搭的模板开发起来才最顺手。本人很反感拿别人的模板直接上手开发,如果非要拿别人的模板开发也会尽量先吃透再用,不吃透就没有代码的掌控感和安全感
|
||||
### 为什么要开发这个模板
|
||||
|
||||
## 功能
|
||||
- Vue3 和 Vite 已经趋于成熟,学习 vite 和 vue3 非常有必要,通过开发模板进行学习是一个很好的方式,事实也证明我确实从中获益良多
|
||||
- 目前主流的 Vue3+Vite 后台管理模板都相对复杂,甚至感觉有点花里胡哨(没有贬低的意思,大部分的架构设计都很优秀,只是觉得集成了太多不实用的东西)
|
||||
|
||||
- 🍒 集成 Naive UI,尤大推荐的 UI 组件库,很香,https://www.naiveui.com
|
||||
- 🍑 集成登陆、注销及权限验证(暂只支持角色页面权限,后续考虑添加按钮权限)
|
||||
- 🍐 集成多环境配置,dev、测试、预发布和生产
|
||||
- 🍎 集成 eslint + prettier,代码约束和格式化统一
|
||||
### 功能
|
||||
|
||||
- 🍒 集成 Naive UI,尤大推荐的 UI 组件库,[https://www.naiveui.com](https://www.naiveui.com)
|
||||
- 🍑 集成登陆、注销及权限验证
|
||||
- 🍐 集成多环境配置,dev、测试、生产和github pages环境
|
||||
- 🍎 集成 Eslint + Prettier,代码约束和格式化统一
|
||||
- 🍉 集成 Mock 接口服务,dev 环境和发布环境都支持,可动态配置是否启用 mock 服务,不启用时不会加载 mock 包,减少打包体积
|
||||
- 🍇 集成 unocss,antfu 大神开源的原子化 css 解决方案,非常轻量,目前我是自己写 scss 样式搭配着 unocss 使用的,很香
|
||||
- 🍍 集成 pinia,Vuex 的替代方案,轻量、简单、易用,很香
|
||||
- 🍏 集成 axios,支持多 axios 实例,支持线上环境免重新打包修改 baseURL
|
||||
- 🍇 集成 unocss,antfu 大神开源的原子化 css 解决方案,非常轻量,目前我是自己写 scss 样式搭配着 unocss 使用的
|
||||
- 🍍 集成 Pinia,Vuex 的替代方案,轻量、简单、易用(尤大已表示不会有Vuex5,或者说pinia就是Vuex5)
|
||||
- 📦 集成 Vite 自动导入插件unplugin-vue-components,解放双手,开发效率直接起飞
|
||||
- 🤹 集成 unplugin-icons插件,优雅使用iconify图标
|
||||
- 🍏 二次封装 Axios,支持多 axios 实例
|
||||
- 🍌 二次封装全局 Dialog、Message、LoadingBar 组件
|
||||
- 🍋 二次封装 localStorage 和 sessionStorage,支持设置过期时间
|
||||
|
||||
## 预览
|
||||
### 预览
|
||||
|
||||
[template.qszone.com](https://template.qszone.com)
|
||||
|
||||
## 文档
|
||||
[github pages](https://zclzone.github.io/vue-naive-admin)
|
||||
|
||||
### 文档
|
||||
|
||||
[Vue Naive Admin Docs](https://zclzone.github.io/vue-naive-admin-docs)
|
||||
|
||||
[羽雀文档:Vue Naive Admin](https://www.yuque.com/qszone/vue-naive-admin)
|
||||
|
||||
## 构建步骤
|
||||
### 构建
|
||||
|
||||
```shell
|
||||
# 推荐配置git autocrlf 为 false(本项目规范使用lf换行符,此配置是为防止git自动将源文件转换为crlf)
|
||||
@@ -51,20 +67,20 @@ pnpm i # 或者 npm i
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 发布
|
||||
### 发布
|
||||
|
||||
```shell
|
||||
# 构建测试环境
|
||||
npm run build:test
|
||||
|
||||
# 构建预发布环境
|
||||
npm run build:staging
|
||||
# 构建github pages环境
|
||||
npm run build:github
|
||||
|
||||
# 构建生产环境
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 其他指令
|
||||
### 其他指令
|
||||
|
||||
```shell
|
||||
# eslint代码格式检查
|
||||
@@ -77,7 +93,9 @@ npm run lint:fix
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Git 提交规范
|
||||
### 规范
|
||||
|
||||
#### git commit 规范
|
||||
|
||||
- `feat` 增加新功能
|
||||
- `fix` 修复问题/BUG
|
||||
@@ -93,3 +111,12 @@ npm run preview
|
||||
- `types` 类型定义文件更改
|
||||
- `wip` 开发中
|
||||
- `mod` 不确定分类的修改
|
||||
- `release` 发布
|
||||
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="https://assets.qszone.com/image/Snipaste_2022-06-23_19-26-26.png" />
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export const GLOB_CONFIG_FILE_NAME = 'app.config.js'
|
||||
export const GLOB_CONFIG_NAME = '__APP__GLOB__CONF__'
|
||||
export const OUTPUT_DIR = 'dist'
|
||||
|
||||
15
build/plugin/html.js
Normal file
@@ -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
@@ -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
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function configMockPlugin(isBuild) {
|
||||
localEnabled: !isBuild,
|
||||
prodEnabled: isBuild,
|
||||
injectCode: `
|
||||
import { setupProdMockServer } from '../mock/_createProdServer';
|
||||
import { setupProdMockServer } from '../mock/_create-prod-server';
|
||||
setupProdMockServer();
|
||||
`,
|
||||
})
|
||||
32
build/plugin/unplugin.js
Normal file
@@ -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' })],
|
||||
}),
|
||||
]
|
||||
@@ -4,10 +4,10 @@ import { OUTPUT_DIR } from '../constant'
|
||||
import { getEnvConfig, getRootPath } from '../utils'
|
||||
|
||||
export function runBuildCNAME() {
|
||||
const { VITE_APP_GLOB_CNAME } = getEnvConfig()
|
||||
if (!VITE_APP_GLOB_CNAME) return
|
||||
const { VITE_APP_CNAME } = getEnvConfig()
|
||||
if (!VITE_APP_CNAME) return
|
||||
try {
|
||||
writeFileSync(getRootPath(`${OUTPUT_DIR}/CNAME`), VITE_APP_GLOB_CNAME)
|
||||
writeFileSync(getRootPath(`${OUTPUT_DIR}/CNAME`), VITE_APP_CNAME)
|
||||
} catch (error) {
|
||||
console.log(chalk.red('CNAME file failed to package:\n' + error))
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import chalk from 'chalk'
|
||||
import { runBuildConfig } from './build-config'
|
||||
import { runBuildCNAME } from './build-cname'
|
||||
|
||||
export const runBuild = async () => {
|
||||
try {
|
||||
runBuildConfig()
|
||||
runBuildCNAME()
|
||||
console.log(`✨ ${chalk.cyan('build successfully!')}`)
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
const httpsReg = /^https:\/\//
|
||||
|
||||
export function wrapperEnv(envOptions) {
|
||||
if (!envOptions) return {}
|
||||
const ret = {}
|
||||
@@ -14,7 +16,7 @@ export function wrapperEnv(envOptions) {
|
||||
if (['VITE_PORT'].includes(key)) {
|
||||
val = +val
|
||||
}
|
||||
if (key === 'VITE_PROXY' && val) {
|
||||
if (key === 'VITE_PROXY' && val && typeof val === 'string') {
|
||||
try {
|
||||
val = JSON.parse(val.replace(/'/g, '"'))
|
||||
} catch (error) {
|
||||
@@ -22,15 +24,33 @@ export function wrapperEnv(envOptions) {
|
||||
}
|
||||
}
|
||||
ret[key] = val
|
||||
if (typeof key === 'string') {
|
||||
if (typeof val === 'string') {
|
||||
process.env[key] = val
|
||||
} else if (typeof key === 'object') {
|
||||
} else if (typeof val === 'object') {
|
||||
process.env[key] = JSON.stringify(val)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
export function createProxy(list = []) {
|
||||
const ret = {}
|
||||
for (const [prefix, target] of list) {
|
||||
const isHttps = httpsReg.test(target)
|
||||
|
||||
// https://github.com/http-party/node-http-proxy#options
|
||||
ret[prefix] = {
|
||||
target: target,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
|
||||
// https is require secure=false
|
||||
...(isHttps ? { secure: false } : {}),
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前环境下生效的配置文件名
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
|
||||
|
||||
import { unocss } from './unocss'
|
||||
import { configHtmlPlugin } from './html'
|
||||
import { configMockPlugin } from './mock'
|
||||
|
||||
export function createVitePlugins(viteEnv, isBuild) {
|
||||
const plugins = [
|
||||
vue(),
|
||||
Components({
|
||||
resolvers: [NaiveUiResolver()],
|
||||
}),
|
||||
VueSetupExtend(),
|
||||
unocss(),
|
||||
configHtmlPlugin(viteEnv, isBuild),
|
||||
]
|
||||
|
||||
viteEnv?.VITE_APP_USE_MOCK && plugins.push(configMockPlugin(isBuild))
|
||||
|
||||
return plugins
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
29
index.html
@@ -1,6 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cn">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
@@ -8,15 +7,31 @@
|
||||
<meta http-equiv="Cache-control" content="no-cache" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>
|
||||
<%= title %>
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/resource/loading.css" />
|
||||
|
||||
<title><%= title %></title>
|
||||
</head>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -3,7 +3,8 @@
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@ const users = {
|
||||
admin: {
|
||||
id: 1,
|
||||
name: '大脸怪(admin)',
|
||||
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
|
||||
avatar: 'https://assets.qszone.com/images/avatar.jpg',
|
||||
email: 'Ronnie@123.com',
|
||||
role: ['admin'],
|
||||
},
|
||||
editor: {
|
||||
id: 2,
|
||||
name: '大脸怪(editor)',
|
||||
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
|
||||
avatar: 'https://assets.qszone.com/images/avatar.jpg',
|
||||
email: 'Ronnie@123.com',
|
||||
role: ['editor'],
|
||||
},
|
||||
guest: {
|
||||
id: 3,
|
||||
name: '访客(guest)',
|
||||
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
|
||||
avatar: 'https://assets.qszone.com/images/avatar.jpg',
|
||||
role: [],
|
||||
},
|
||||
}
|
||||
|
||||
55
package.json
@@ -1,47 +1,48 @@
|
||||
{
|
||||
"name": "vue-naive-admin",
|
||||
"version": "0.0.1",
|
||||
"version": "0.4.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"lint": "eslint --ext .js,.vue .",
|
||||
"lint:fix": "eslint --fix --ext .js,.vue .",
|
||||
"build": "vite build && esno ./build/script",
|
||||
"build:test": "vite build --mode test && esno ./build/script",
|
||||
"build:staging": "vite build --mode staging && esno ./build/script",
|
||||
"build": "vite build",
|
||||
"build:test": "vite build --mode test",
|
||||
"build:github": "vite build --mode github && esno ./build/script",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/fa": "^0.11.0",
|
||||
"@vueuse/core": "^8.4.2",
|
||||
"axios": "^0.21.4",
|
||||
"dayjs": "^1.10.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md-editor-v3": "^1.10.2",
|
||||
"dayjs": "^1.11.0",
|
||||
"md-editor-v3": "^1.11.4",
|
||||
"mockjs": "^1.1.0",
|
||||
"pinia": "^2.0.11",
|
||||
"vue": "^3.2.30",
|
||||
"vue-router": "^4.0.12"
|
||||
"pinia": "^2.0.13",
|
||||
"vue": "^3.2.31",
|
||||
"vue-router": "^4.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@unocss/preset-attributify": "^0.16.4",
|
||||
"@unocss/preset-icons": "^0.16.4",
|
||||
"@unocss/preset-uno": "^0.16.4",
|
||||
"@iconify/json": "^2.1.63",
|
||||
"@iconify/vue": "^3.2.1",
|
||||
"@vitejs/plugin-vue": "^1.10.2",
|
||||
"@vue/compiler-sfc": "^3.2.30",
|
||||
"chalk": "^5.0.0",
|
||||
"@vue/compiler-sfc": "^3.2.31",
|
||||
"chalk": "^5.0.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"eslint": "^8.6.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint": "^8.12.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-vue": "^8.2.0",
|
||||
"eslint-plugin-vue": "^8.5.0",
|
||||
"esno": "^0.13.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"naive-ui": "^2.25.2",
|
||||
"prettier": "^2.5.1",
|
||||
"sass": "^1.38.1",
|
||||
"unocss": "^0.16.4",
|
||||
"unplugin-vue-components": "^0.17.18",
|
||||
"vite": "^2.8.0",
|
||||
"vite-plugin-html": "^2.1.1",
|
||||
"fs-extra": "^10.0.1",
|
||||
"naive-ui": "^2.30.3",
|
||||
"prettier": "^2.6.1",
|
||||
"rollup-plugin-visualizer": "^5.6.0",
|
||||
"sass": "^1.49.10",
|
||||
"unocss": "^0.38.2",
|
||||
"unplugin-auto-import": "^0.8.8",
|
||||
"unplugin-icons": "^0.14.1",
|
||||
"unplugin-vue-components": "^0.17.21",
|
||||
"vite": "^2.9.9",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"vite-plugin-mock": "^2.9.6",
|
||||
"vite-plugin-vue-setup-extend": "^0.3.0"
|
||||
}
|
||||
|
||||
1700
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 2.0 KiB |
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512" data-v-fba6e5d0=""><path d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-92.2 312.9c-63.4 0-85.4-28.6-97.1-64.1c-16.3-51-21.5-84.3-63-84.3c-22.4 0-45.1 16.1-45.1 61.2c0 35.2 18 57.2 43.3 57.2c28.6 0 47.6-21.3 47.6-21.3l11.7 31.9s-19.8 19.4-61.2 19.4c-51.3 0-79.9-30.1-79.9-85.8c0-57.9 28.6-92 82.5-92c73.5 0 80.8 41.4 100.8 101.9c8.8 26.8 24.2 46.2 61.2 46.2c24.9 0 38.1-5.5 38.1-19.1c0-19.9-21.8-22-49.9-28.6c-30.4-7.3-42.5-23.1-42.5-48c0-40 32.3-52.4 65.2-52.4c37.4 0 60.1 13.6 63 46.6l-36.7 4.4c-1.5-15.8-11-22.4-28.6-22.4c-16.1 0-26 7.3-26 19.8c0 11 4.8 17.6 20.9 21.3c32.7 7.1 71.8 12 71.8 57.5c.1 36.7-30.7 50.6-76.1 50.6z" fill="#316c72"></path></svg>
|
||||
|
After Width: | Height: | Size: 825 B |
91
public/resource/loading.css
Normal 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;
|
||||
}
|
||||
25
public/resource/loading.js
Normal 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');
|
||||
12
src/App.vue
@@ -1,15 +1,15 @@
|
||||
<script setup>
|
||||
import AppProvider from '@/components/AppProvider/index.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<app-provider>
|
||||
<AppProvider>
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
</app-provider>
|
||||
</AppProvider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppProvider from '@/components/common/AppProvider.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app {
|
||||
height: 100%;
|
||||
|
||||
1
src/assets/icons/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512" data-v-fba6e5d0=""><path d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-92.2 312.9c-63.4 0-85.4-28.6-97.1-64.1c-16.3-51-21.5-84.3-63-84.3c-22.4 0-45.1 16.1-45.1 61.2c0 35.2 18 57.2 43.3 57.2c28.6 0 47.6-21.3 47.6-21.3l11.7 31.9s-19.8 19.4-61.2 19.4c-51.3 0-79.9-30.1-79.9-85.8c0-57.9 28.6-92 82.5-92c73.5 0 80.8 41.4 100.8 101.9c8.8 26.8 24.2 46.2 61.2 46.2c24.9 0 38.1-5.5 38.1-19.1c0-19.9-21.8-22-49.9-28.6c-30.4-7.3-42.5-23.1-42.5-48c0-40 32.3-52.4 65.2-52.4c37.4 0 60.1 13.6 63 46.6l-36.7 4.4c-1.5-15.8-11-22.4-28.6-22.4c-16.1 0-26 7.3-26 19.8c0 11 4.8 17.6 20.9 21.3c32.7 7.1 71.8 12 71.8 57.5c.1 36.7-30.7 50.6-76.1 50.6z" fill="#316c72"></path></svg>
|
||||
|
After Width: | Height: | Size: 825 B |
BIN
src/assets/images/404.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src/assets/images/login_banner.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/assets/images/login_bg.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,52 +0,0 @@
|
||||
<script setup>
|
||||
import { isNullOrUndef } from '@/utils/is'
|
||||
import { useDialog } from 'naive-ui'
|
||||
|
||||
const NDialog = useDialog()
|
||||
|
||||
class Dialog {
|
||||
success(title, option) {
|
||||
this.showDialog('success', { title, ...option })
|
||||
}
|
||||
|
||||
warning(title, option) {
|
||||
this.showDialog('warning', { title, ...option })
|
||||
}
|
||||
|
||||
error(title, option) {
|
||||
this.showDialog('error', { title, ...option })
|
||||
}
|
||||
|
||||
showDialog(type = 'success', option) {
|
||||
if (isNullOrUndef(option.title)) {
|
||||
// ! 没有title的情况
|
||||
option.showIcon = false
|
||||
}
|
||||
NDialog[type]({
|
||||
positiveText: 'OK',
|
||||
closable: false,
|
||||
...option,
|
||||
})
|
||||
}
|
||||
|
||||
confirm(option = {}) {
|
||||
this.showDialog(option.type || 'error', {
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: option.confirm,
|
||||
onNegativeClick: option.cancel,
|
||||
onMaskClick: option.cancel,
|
||||
...option,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window['$dialog'] = new Dialog()
|
||||
Object.freeze(window.$dialog)
|
||||
Object.defineProperty(window, '$dialog', {
|
||||
configurable: false,
|
||||
writable: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script setup>
|
||||
import { useLoadingBar } from 'naive-ui'
|
||||
window['$loadingBar'] = useLoadingBar()
|
||||
Object.defineProperty(window, '$loadingBar', {
|
||||
configurable: false,
|
||||
writable: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script setup>
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
const NMessage = useMessage()
|
||||
|
||||
let loadingMessage = null
|
||||
|
||||
class Message {
|
||||
/**
|
||||
* 规则:
|
||||
* * loading message只显示一个,新的message会替换正在显示的loading message
|
||||
* * loading message不会自动清除,除非被替换成非loading message,非loading message默认2秒后自动清除
|
||||
*/
|
||||
|
||||
removeMessage(message, duration = 2000) {
|
||||
setTimeout(() => {
|
||||
if (message) {
|
||||
message.destroy()
|
||||
message = null
|
||||
}
|
||||
}, duration)
|
||||
}
|
||||
|
||||
showMessage(type, content, option = {}) {
|
||||
if (loadingMessage && loadingMessage.type === 'loading') {
|
||||
// 如果存在则替换正在显示的loading message
|
||||
loadingMessage.type = type
|
||||
loadingMessage.content = content
|
||||
|
||||
if (type !== 'loading') {
|
||||
// 非loading message需设置自动清除
|
||||
this.removeMessage(loadingMessage, option.duration)
|
||||
}
|
||||
} else {
|
||||
// 不存在正在显示的loading则新建一个message,如果新建的message是loading message则将message赋值存储下来
|
||||
let message = NMessage[type](content, option)
|
||||
if (type === 'loading') {
|
||||
loadingMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loading(content) {
|
||||
this.showMessage('loading', content, { duration: 0 })
|
||||
}
|
||||
|
||||
success(content, option = {}) {
|
||||
this.showMessage('success', content, option)
|
||||
}
|
||||
|
||||
error(content, option = {}) {
|
||||
this.showMessage('error', content, option)
|
||||
}
|
||||
|
||||
info(content, option = {}) {
|
||||
this.showMessage('info', content, option)
|
||||
}
|
||||
|
||||
warning(content, option = {}) {
|
||||
this.showMessage('warning', content, option)
|
||||
}
|
||||
}
|
||||
|
||||
window['$message'] = new Message()
|
||||
|
||||
Object.defineProperty(window, '$message', {
|
||||
configurable: false,
|
||||
writable: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import MessageContent from './MessageContent.vue'
|
||||
import DialogContent from './DialogContent.vue'
|
||||
import LoadingBar from './LoadingBar.vue'
|
||||
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme-overrides="appStore.themeOverrides">
|
||||
<n-loading-bar-provider>
|
||||
<loading-bar />
|
||||
<n-dialog-provider>
|
||||
<dialog-content />
|
||||
<n-message-provider>
|
||||
<message-content />
|
||||
<slot></slot>
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
55
src/components/common/AppProvider.vue
Normal 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>
|
||||
143
src/components/common/ScrollX.vue
Normal 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>
|
||||
@@ -2,15 +2,16 @@
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade-slide" mode="out-in" appear>
|
||||
<keep-alive :include="keepAliveRouteNames">
|
||||
<component :is="Component" :key="route.path" />
|
||||
<component :is="Component" v-if="appStore.reloadFlag" :key="route.path" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const allRoutes = router.getRoutes()
|
||||
const keepAliveRouteNames = computed(() => {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
const { currentRoute } = router
|
||||
|
||||
function handleBreadClick(path) {
|
||||
if (path === currentRoute.value.path) return
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-breadcrumb>
|
||||
<n-breadcrumb-item v-for="item in currentRoute.matched" :key="item.path" @click="handleBreadClick(item.path)">
|
||||
{{ item.meta.title }}
|
||||
</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</template>
|
||||
@@ -1,96 +0,0 @@
|
||||
<script setup>
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { resetRouter } from '@/router'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
import { NOT_FOUND_ROUTE } from '@/router/routes'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: '切换角色',
|
||||
key: 'switchRole',
|
||||
},
|
||||
{
|
||||
label: '退出登录',
|
||||
key: 'logout',
|
||||
},
|
||||
]
|
||||
|
||||
function handleSelect(key) {
|
||||
if (key === 'logout') {
|
||||
logout()
|
||||
} else if (key === 'switchRole') {
|
||||
switchRole()
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
userStore.logout()
|
||||
$message.success('已退出登录')
|
||||
router.push({ path: '/login' })
|
||||
}
|
||||
|
||||
function switchRole() {
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: 1,
|
||||
name: '大脸怪(admin)',
|
||||
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
|
||||
email: 'Ronnie@123.com',
|
||||
role: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '大脸怪(editor)',
|
||||
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
|
||||
email: 'Ronnie@123.com',
|
||||
role: ['editor'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '访客(guest)',
|
||||
avatar: 'https://gitee.com/zclzone/res/raw/master/qs-zone/blob/img/lADPDiQ3QDTwsz3NAarNAaw_428_426.jpg',
|
||||
role: [],
|
||||
},
|
||||
]
|
||||
|
||||
const switchUser = users[+userStore.userId % users.length]
|
||||
resetRouter()
|
||||
userStore.setUserInfo(switchUser)
|
||||
const accessRoutes = permissionStore.generateRoutes(switchUser.role)
|
||||
accessRoutes.forEach((route) => {
|
||||
!router.hasRoute(route.name) && router.addRoute(route)
|
||||
})
|
||||
router.addRoute(NOT_FOUND_ROUTE)
|
||||
$message.success(`${switchUser.name}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-dropdown :options="options" @select="handleSelect">
|
||||
<div class="avatar">
|
||||
<img :src="userStore.avatar" />
|
||||
<span>{{ userStore.name }}</span>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
img {
|
||||
width: 100%;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
src/layout/components/header/components/BreadCrumb.vue
Normal 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>
|
||||
12
src/layout/components/header/components/FullScreen.vue
Normal 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>
|
||||
13
src/layout/components/header/components/GithubSite.vue
Normal 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>
|
||||
12
src/layout/components/header/components/MenuCollapse.vue
Normal 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>
|
||||
52
src/layout/components/header/components/UserAvatar.vue
Normal 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>
|
||||
@@ -1,21 +1,39 @@
|
||||
<script setup>
|
||||
import BreadCrumb from './BreadCrumb.vue'
|
||||
import HeaderAction from './HeaderAction.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="header">
|
||||
<bread-crumb />
|
||||
<header-action />
|
||||
<div class="h-left">
|
||||
<MenuCollapse />
|
||||
<BreadCrumb ml-15 />
|
||||
</div>
|
||||
<div class="h-right">
|
||||
<GithubSite />
|
||||
<FullScreen />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BreadCrumb from './components/BreadCrumb.vue'
|
||||
import MenuCollapse from './components/MenuCollapse.vue'
|
||||
import FullScreen from './components/FullScreen.vue'
|
||||
import UserAvatar from './components/UserAvatar.vue'
|
||||
import GithubSite from './components/GithubSite.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
padding: 0 24px;
|
||||
padding: 0 15px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.h-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.h-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup>
|
||||
import { LastfmSquare } from '@vicons/fa'
|
||||
const title = import.meta.env.VITE_APP_TITLE
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logo">
|
||||
<n-icon size="36" color="#316c72">
|
||||
<lastfm-square />
|
||||
</n-icon>
|
||||
<router-link to="/">
|
||||
<n-gradient-text type="primary">{{ title }}</n-gradient-text>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
a {
|
||||
margin-left: 5px;
|
||||
.n-gradient-text {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, h } from 'vue'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { ListAlt, CircleRegular } from '@vicons/fa'
|
||||
|
||||
import { isExternal } from '@/utils/is'
|
||||
|
||||
const router = useRouter()
|
||||
const permissionStore = usePermissionStore()
|
||||
const { currentRoute } = router
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
return generateOptions(permissionStore.routes, '')
|
||||
})
|
||||
|
||||
function resolvePath(basePath, path) {
|
||||
if (isExternal(path)) return path
|
||||
return (
|
||||
'/' +
|
||||
[basePath, path]
|
||||
.filter((path) => !!path && path !== '/')
|
||||
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
|
||||
.join('/')
|
||||
)
|
||||
}
|
||||
|
||||
function renderIcon(icon, props = { size: 12 }) {
|
||||
return () => h(NIcon, { ...props }, { default: () => h(icon) })
|
||||
}
|
||||
|
||||
function isSingleRoute(route) {
|
||||
let isSingle = true
|
||||
let curRoute = route
|
||||
while (curRoute.children && curRoute.children.length) {
|
||||
if (curRoute.children.length > 1) {
|
||||
isSingle = false
|
||||
break
|
||||
}
|
||||
if (curRoute.children.length === 1) {
|
||||
curRoute = curRoute.children[0]
|
||||
}
|
||||
}
|
||||
return isSingle
|
||||
}
|
||||
|
||||
function generateOptions(routes, basePath) {
|
||||
let options = []
|
||||
routes.forEach((route) => {
|
||||
if (route.name && !route.isHidden) {
|
||||
let curOption = {
|
||||
label: (route.meta && route.meta.title) || route.name,
|
||||
key: route.name,
|
||||
path: resolvePath(basePath, route.path),
|
||||
}
|
||||
if (route.children && route.children.length) {
|
||||
curOption.icon = renderIcon(route.meta?.icon || ListAlt, { size: 16 })
|
||||
curOption.children = generateOptions(route.children, resolvePath(basePath, route.path))
|
||||
} else {
|
||||
curOption.icon = (route.meta?.icon && renderIcon(route.meta?.icon)) || renderIcon(CircleRegular, { size: 8 })
|
||||
}
|
||||
options.push(curOption)
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
function handleMenuSelect(key, item) {
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path)
|
||||
} else {
|
||||
router.push(item.path)
|
||||
}
|
||||
|
||||
// 通过path重定向
|
||||
// router.push({
|
||||
// path: '/redirect',
|
||||
// query: { redirect: item.path },
|
||||
// })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-menu
|
||||
class="side-menu"
|
||||
accordion
|
||||
:indent="12"
|
||||
:root-indent="12"
|
||||
:options="menuOptions"
|
||||
:value="(currentRoute.meta && currentRoute.meta.activeMenu) || currentRoute.name"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.n-menu {
|
||||
margin-top: 10px;
|
||||
padding-left: 10px;
|
||||
.n-menu-item {
|
||||
margin-top: 0;
|
||||
position: relative;
|
||||
&::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 0;
|
||||
background-color: unset !important;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.n-menu-item--selected {
|
||||
border-radius: 0 !important;
|
||||
|
||||
&::before {
|
||||
border-right: 3px solid $primaryColor;
|
||||
background-color: #16243a;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba($primaryColor, 0.3) 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.n-menu-item-content-header {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.n-submenu-children {
|
||||
.n-menu-item-content-header {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
position: relative;
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/layout/components/sidebar/components/SideLogo.vue
Normal 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>
|
||||
110
src/layout/components/sidebar/components/SideMenu.vue
Normal 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>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import SideLogo from './SideLogo.vue'
|
||||
import SideMenu from './SideMenu.vue'
|
||||
import SideLogo from './components/SideLogo.vue'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<side-logo />
|
||||
<side-menu />
|
||||
<SideLogo />
|
||||
<SideMenu />
|
||||
</template>
|
||||
|
||||
126
src/layout/components/tags/ContextMenu.vue
Normal 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>
|
||||
107
src/layout/components/tags/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
<div class="layout">
|
||||
<n-layout has-sider position="absolute">
|
||||
<n-layout-sider :width="200" :collapsed-width="0" :native-scrollbar="false">
|
||||
<side-menu />
|
||||
<n-layout has-sider style="height: 100%">
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="220"
|
||||
:native-scrollbar="false"
|
||||
:collapsed="appStore.collapsed"
|
||||
>
|
||||
<SideBar />
|
||||
</n-layout-sider>
|
||||
<n-layout>
|
||||
<n-layout-header>
|
||||
<app-header />
|
||||
<n-layout-header :style="{ height: useTheme.header.height + 'px' }">
|
||||
<AppHeader />
|
||||
</n-layout-header>
|
||||
<n-layout position="absolute" style="top: 60px; background-color: #f5f6fb" :native-scrollbar="false">
|
||||
<app-main />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppHeader from './components/header/index.vue'
|
||||
import SideBar from './components/sidebar/index.vue'
|
||||
import AppMain from './components/AppMain.vue'
|
||||
import AppTags from './components/tags/index.vue'
|
||||
import { useThemeStore } from '@/store/modules/theme'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const useTheme = useThemeStore()
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.n-layout-header {
|
||||
height: 60px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-left: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
|
||||
12
src/main.js
@@ -1,19 +1,21 @@
|
||||
import '@/styles/reset.css'
|
||||
import '@/styles/variables.css'
|
||||
import '@/styles/index.scss'
|
||||
import 'uno.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
import { setupRouter } from '@/router'
|
||||
import { setupStore } from '@/store'
|
||||
import App from './App.vue'
|
||||
|
||||
async function bootstrap() {
|
||||
function setupApp() {
|
||||
const app = createApp(App)
|
||||
|
||||
setupStore(app)
|
||||
|
||||
setupRouter(app)
|
||||
|
||||
app.mount('#app', true)
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
setupApp()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createPageLoadingGuard } from './pageLoadingGuard'
|
||||
import { createPermissionGuard } from './permissionGuard'
|
||||
import { createPageLoadingGuard } from './page-loading-guard'
|
||||
import { createPageTitleGuard } from './page-title-guard'
|
||||
import { createPermissionGuard } from './permission-guard'
|
||||
|
||||
export function setupRouterGuard(router) {
|
||||
createPageLoadingGuard(router)
|
||||
createPermissionGuard(router)
|
||||
createPageTitleGuard(router)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export function createPageLoadingGuard(router) {
|
||||
router.beforeEach(() => {
|
||||
$loadingBar.start()
|
||||
window.$loadingBar?.start()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
setTimeout(() => {
|
||||
$loadingBar.finish()
|
||||
window.$loadingBar?.finish()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
$loadingBar.error()
|
||||
window.$loadingBar?.error()
|
||||
})
|
||||
}
|
||||
12
src/router/guard/page-title-guard.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const baseTitle = import.meta.env.VITE_APP_TITLE
|
||||
|
||||
export function createPageTitleGuard(router) {
|
||||
router.afterEach((to) => {
|
||||
const pageTitle = to.meta?.title
|
||||
if (pageTitle) {
|
||||
document.title = `${pageTitle} | ${baseTitle}`
|
||||
} else {
|
||||
document.title = baseTitle
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useUserStore } from '@/store/modules/user'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
import { NOT_FOUND_ROUTE } from '@/router/routes'
|
||||
import { getToken, refreshAccessToken, removeToken } from '@/utils/token'
|
||||
import { toLogin } from '@/utils/auth'
|
||||
|
||||
const WHITE_LIST = ['/login', '/redirect']
|
||||
export function createPermissionGuard(router) {
|
||||
@@ -18,19 +19,18 @@ export function createPermissionGuard(router) {
|
||||
refreshAccessToken()
|
||||
next()
|
||||
} else {
|
||||
try {
|
||||
await userStore.getUserInfo()
|
||||
await userStore.getUserInfo().catch((error) => {
|
||||
removeToken()
|
||||
toLogin()
|
||||
$message.error(error.message || '获取用户信息失败!')
|
||||
return
|
||||
})
|
||||
const accessRoutes = permissionStore.generateRoutes(userStore.role)
|
||||
accessRoutes.forEach((route) => {
|
||||
!router.hasRoute(route.name) && router.addRoute(route)
|
||||
})
|
||||
router.addRoute(NOT_FOUND_ROUTE)
|
||||
next({ ...to, replace: true })
|
||||
} catch (error) {
|
||||
removeToken()
|
||||
$message.error(error)
|
||||
next({ path: '/login', query: { ...to.query, redirect: to.path } })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||
import { setupRouterGuard } from './guard'
|
||||
import { basicRoutes } from './routes'
|
||||
|
||||
const isHash = !!import.meta.env.VITE_APP_USE_HASH
|
||||
export const router = createRouter({
|
||||
history: createWebHistory('/'),
|
||||
routes: basicRoutes,
|
||||
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
|
||||
routes: [],
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
})
|
||||
|
||||
@@ -19,6 +20,9 @@ export function resetRouter() {
|
||||
}
|
||||
|
||||
export function setupRouter(app) {
|
||||
basicRoutes.forEach((route) => {
|
||||
!router.hasRoute(route.name) && router.addRoute(route)
|
||||
})
|
||||
app.use(router)
|
||||
setupRouterGuard(router)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Layout from '@/layout/index.vue'
|
||||
import Home from '@/views/dashboard/index.vue'
|
||||
import { ChartBar, Dove, Github, HouseDamage, Link, TimesCircle } from '@vicons/fa'
|
||||
|
||||
export const basicRoutes = [
|
||||
{
|
||||
@@ -33,64 +32,88 @@ export const basicRoutes = [
|
||||
},
|
||||
|
||||
{
|
||||
name: 'DASHBOARD',
|
||||
name: 'Dashboard',
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/home',
|
||||
meta: {
|
||||
title: 'Dashboard',
|
||||
icon: ChartBar,
|
||||
icon: 'mdi:chart-bar',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'HOME',
|
||||
name: 'Home',
|
||||
path: 'home',
|
||||
component: Home,
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: HouseDamage,
|
||||
icon: 'mdi:home',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'TEST',
|
||||
name: 'ErrorPage',
|
||||
path: '/error-page',
|
||||
component: Layout,
|
||||
redirect: '/error-page/404',
|
||||
meta: {
|
||||
title: '错误页',
|
||||
icon: 'mdi:alert-circle-outline',
|
||||
index: 4,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'ERROR-404',
|
||||
path: '404',
|
||||
component: () => import('@/views/error-page/404.vue'),
|
||||
meta: {
|
||||
title: '404',
|
||||
icon: 'mdi:alert-circle-outline',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Test',
|
||||
path: '/test',
|
||||
component: Layout,
|
||||
redirect: '/test/unocss',
|
||||
meta: {
|
||||
title: '基础功能测试',
|
||||
icon: 'mdi:menu',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'UNOCSS',
|
||||
name: 'Unocss',
|
||||
path: 'unocss',
|
||||
component: () => import('@/views/test-page/TestUnocss.vue'),
|
||||
component: () => import('@/views/test-page/unocss/index.vue'),
|
||||
meta: {
|
||||
title: '测试unocss',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MESSAGE',
|
||||
name: 'Message',
|
||||
path: 'message',
|
||||
component: () => import('@/views/test-page/TestMessage.vue'),
|
||||
component: () => import('@/views/test-page/message/index.vue'),
|
||||
meta: {
|
||||
title: '测试Message',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'DIALOG',
|
||||
name: 'Dialog',
|
||||
path: 'dialog',
|
||||
component: () => import('@/views/test-page/TestDialog.vue'),
|
||||
component: () => import('@/views/test-page/dialog/index.vue'),
|
||||
meta: {
|
||||
title: '测试Dialog',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'TEST-KEEP-ALIVE',
|
||||
name: 'TestKeepAlive',
|
||||
path: 'keep-alive',
|
||||
component: () => import('@/views/test-page/TestKeepAlive.vue'),
|
||||
component: () => import('@/views/test-page/keep-alive/index.vue'),
|
||||
meta: {
|
||||
title: '测试Keep-Alive',
|
||||
keepAlive: true,
|
||||
@@ -100,56 +123,36 @@ export const basicRoutes = [
|
||||
},
|
||||
|
||||
{
|
||||
name: 'ERROR-PAGE',
|
||||
path: '/error-page',
|
||||
component: Layout,
|
||||
redirect: '/error-page/404',
|
||||
meta: {
|
||||
title: '错误页',
|
||||
icon: TimesCircle,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'ERROR-404',
|
||||
path: '404',
|
||||
component: () => import('@/views/error-page/404.vue'),
|
||||
meta: {
|
||||
title: '404',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'EXTERNAL-LINK',
|
||||
name: 'ExternalLink',
|
||||
path: '/external-link',
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: '外部链接',
|
||||
icon: Link,
|
||||
icon: 'mdi:link-variant',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'LINK-GITHUB-SRC',
|
||||
name: 'LinkGithubSrc',
|
||||
path: 'https://github.com/zclzone/vue-naive-admin',
|
||||
meta: {
|
||||
title: '源码 - github',
|
||||
icon: Github,
|
||||
icon: 'mdi:github',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'LINK-GITEE-SRC',
|
||||
name: 'LinkGiteeSrc',
|
||||
path: 'https://gitee.com/zclzone/vue-naive-admin',
|
||||
meta: {
|
||||
title: '源码 - gitee',
|
||||
icon: 'simple-icons:gitee',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'LINK-DOCS',
|
||||
path: 'https://www.yuque.com/qszone/vue-naive-admin',
|
||||
name: 'LinkDocs',
|
||||
path: 'https://zclzone.github.io/vue-naive-admin-docs',
|
||||
meta: {
|
||||
title: '文档 - 语雀',
|
||||
icon: Dove,
|
||||
title: '文档 - vuepress',
|
||||
icon: 'mdi:vuejs',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -157,7 +160,7 @@ export const basicRoutes = [
|
||||
]
|
||||
|
||||
export const NOT_FOUND_ROUTE = {
|
||||
name: 'NOT_FOUND',
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404',
|
||||
isHidden: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import Layout from '@/layout/index.vue'
|
||||
|
||||
export default [
|
||||
{
|
||||
name: 'EXAMPLE',
|
||||
name: 'Example',
|
||||
path: '/example',
|
||||
component: Layout,
|
||||
redirect: '/example/table',
|
||||
@@ -12,17 +12,18 @@ export default [
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'EXAMPLE-TABLE',
|
||||
name: 'Table',
|
||||
path: 'table',
|
||||
component: () => import('@/views/examples/table/index.vue'),
|
||||
redirect: '/example/table/post',
|
||||
meta: {
|
||||
title: '表格',
|
||||
role: ['admin'],
|
||||
icon: 'mdi:table',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'POST-LIST',
|
||||
name: 'PostList',
|
||||
path: 'post',
|
||||
component: () => import('@/views/examples/table/post/index.vue'),
|
||||
meta: {
|
||||
@@ -31,9 +32,9 @@ export default [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'POST-CREATE',
|
||||
name: 'PostCreate',
|
||||
path: 'post-create',
|
||||
component: () => import('@/views/examples/table/post/post-create.vue'),
|
||||
component: () => import('@/views/examples/table/post/PostCreate.vue'),
|
||||
meta: {
|
||||
title: '创建文章',
|
||||
role: ['admin'],
|
||||
|
||||
1
src/settings/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as themeSettings } from './theme.json'
|
||||
17
src/settings/theme.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"tags": {
|
||||
"visible": true,
|
||||
"height": 50
|
||||
},
|
||||
"header": {
|
||||
"height": 60
|
||||
},
|
||||
"naiveThemeOverrides": {
|
||||
"common": {
|
||||
"primaryColor": "#316C72FF",
|
||||
"primaryColorHover": "#316C72E3",
|
||||
"primaryColorPressed": "#2B4C59FF",
|
||||
"primaryColorSuppl": "#316C7263"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,27 @@ import { defineStore } from 'pinia'
|
||||
export const useAppStore = defineStore('app', {
|
||||
state() {
|
||||
return {
|
||||
themeOverrides: {
|
||||
common: {
|
||||
primaryColor: '#316c72',
|
||||
primaryColorSuppl: '#316c72',
|
||||
primaryColorHover: '#316c72',
|
||||
successColorHover: '#316c72',
|
||||
successColorSuppl: '#316c72',
|
||||
},
|
||||
},
|
||||
reloadFlag: true,
|
||||
collapsed: false,
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async reloadPage() {
|
||||
$loadingBar.start()
|
||||
this.reloadFlag = false
|
||||
await nextTick()
|
||||
this.reloadFlag = true
|
||||
|
||||
setTimeout(() => {
|
||||
document.documentElement.scrollTo({ left: 0, top: 0 })
|
||||
$loadingBar.finish()
|
||||
}, 100)
|
||||
},
|
||||
switchCollapsed() {
|
||||
this.collapsed = !this.collapsed
|
||||
},
|
||||
setCollapsed(collapsed) {
|
||||
this.collapsed = collapsed
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -38,6 +38,9 @@ export const usePermissionStore = defineStore('permission', {
|
||||
routes() {
|
||||
return basicRoutes.concat(this.accessRoutes)
|
||||
},
|
||||
menus() {
|
||||
return this.routes.filter((route) => route.name && !route.isHidden)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
generateRoutes(role = []) {
|
||||
|
||||
7
src/store/modules/tags/helpers.js
Normal 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']
|
||||
60
src/store/modules/tags/index.js
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
23
src/store/modules/theme.js
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getUser } from '@/api/user'
|
||||
import { removeToken } from '@/utils/token'
|
||||
import { toLogin } from '@/utils/auth'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state() {
|
||||
@@ -31,16 +32,16 @@ export const useUserStore = defineStore('user', {
|
||||
this.userInfo = { id, name, avatar, role }
|
||||
return Promise.resolve(res.data)
|
||||
} else {
|
||||
return Promise.reject(res.message)
|
||||
return Promise.reject(res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return Promise.reject(error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
async logout() {
|
||||
removeToken()
|
||||
this.userInfo = {}
|
||||
toLogin()
|
||||
},
|
||||
setUserInfo(userInfo = {}) {
|
||||
this.userInfo = { ...this.userInfo, ...userInfo }
|
||||
|
||||
@@ -1,2 +1,54 @@
|
||||
@import './reset.scss';
|
||||
@import './public.scss';
|
||||
html {
|
||||
font-size: 4px; // * 1rem = 4px 方便unocss计算:在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #f2f2f2;
|
||||
font-family: 'Encode Sans Condensed', sans-serif;
|
||||
}
|
||||
|
||||
/* router view transition fade-slide */
|
||||
.fade-slide-leave-active,
|
||||
.fade-slide-enter-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.cur-scroll {
|
||||
&::-webkit-scrollbar{
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb{
|
||||
background-color: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner{
|
||||
background: #f6f6f6;
|
||||
}
|
||||
&:hover {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover{
|
||||
background: #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
:root {
|
||||
--primaryColor: #316c72;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
$primaryColor: #316c72;
|
||||
|
||||
:root {
|
||||
--vh100: 100vh;
|
||||
}
|
||||
8
src/utils/auth.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { router } from '@/router'
|
||||
|
||||
export function toLogin() {
|
||||
router.replace({
|
||||
path: '/login',
|
||||
query: { ...router.currentRoute.query, redirect: router.currentRoute.path },
|
||||
})
|
||||
}
|
||||
12
src/utils/cache/index.js
vendored
@@ -1,9 +1,15 @@
|
||||
import { createWebStorage } from './webStorage'
|
||||
import { createWebStorage } from './web-storage'
|
||||
|
||||
export const createLocalStorage = function (option = {}) {
|
||||
return createWebStorage({ prefixKey: option.prefixKey || '', storage: localStorage })
|
||||
return createWebStorage({
|
||||
prefixKey: option.prefixKey || '',
|
||||
storage: localStorage,
|
||||
})
|
||||
}
|
||||
|
||||
export const createSessionStorage = function (option = {}) {
|
||||
return createWebStorage({ prefixKey: option.prefixKey || '', storage: sessionStorage })
|
||||
return createWebStorage({
|
||||
prefixKey: option.prefixKey || '',
|
||||
storage: sessionStorage,
|
||||
})
|
||||
}
|
||||
|
||||
79
src/utils/common/naiveTools.js
Normal 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
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import axios from 'axios'
|
||||
import { setupInterceptor } from './interceptors'
|
||||
import { repReject, repResolve, reqReject, reqResolve } from './interceptors'
|
||||
|
||||
function createAxios(option = {}) {
|
||||
const defBaseURL = window.__APP__GLOB__CONF__?.VITE_APP_GLOB_BASE_API || import.meta.env.VITE_APP_GLOB_BASE_API
|
||||
export function createAxios(options = {}) {
|
||||
const defaultOptions = {
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: 12000,
|
||||
}
|
||||
const service = axios.create({
|
||||
timeout: option.timeout || 120000,
|
||||
baseURL: option.baseURL || defBaseURL,
|
||||
...defaultOptions,
|
||||
...options,
|
||||
})
|
||||
setupInterceptor(service)
|
||||
service.interceptors.request.use(reqResolve, reqReject)
|
||||
service.interceptors.response.use(repResolve, repReject)
|
||||
return service
|
||||
}
|
||||
|
||||
export const defAxios = createAxios()
|
||||
|
||||
export const testAxios = createAxios({
|
||||
baseURL: window.__APP__GLOB__CONF__?.VITE_APP_GLOB_BASE_API_TEST || import.meta.env.VITE_APP_GLOB_BASE_API_TEST,
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API_TEST,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { router } from '@/router'
|
||||
import { getToken, removeToken } from '@/utils/token'
|
||||
import { isWithoutToken } from './help'
|
||||
import { getToken } from '@/utils/token'
|
||||
import { toLogin } from '@/utils/auth'
|
||||
import { isNullOrUndef } from '@/utils/is'
|
||||
import { isWithoutToken } from './helpers'
|
||||
|
||||
export function setupInterceptor(service) {
|
||||
service.interceptors.request.use(
|
||||
async (config) => {
|
||||
export function reqResolve(config) {
|
||||
// 防止缓存,给get请求加上时间戳
|
||||
if (config.method === 'get') {
|
||||
config.params = { ...config.params, t: new Date().getTime() }
|
||||
@@ -16,67 +15,57 @@ export function setupInterceptor(service) {
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
/**
|
||||
* * jwt token
|
||||
* ! 认证方案: Bearer
|
||||
*/
|
||||
config.headers.Authorization = 'Bearer ' + token
|
||||
|
||||
return config
|
||||
}
|
||||
if (!token) {
|
||||
/**
|
||||
* * 未登录或者token过期的情况下
|
||||
* * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面
|
||||
*/
|
||||
const { currentRoute } = router
|
||||
router.replace({
|
||||
path: '/login',
|
||||
query: { ...currentRoute.query, redirect: currentRoute.path },
|
||||
})
|
||||
toLogin()
|
||||
return Promise.reject({ code: '-1', message: '未登录' })
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
}
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response) => response?.data,
|
||||
(error) => {
|
||||
let { code, message } = error.response?.data
|
||||
return Promise.reject({ code, message })
|
||||
/**
|
||||
* * jwt token
|
||||
* ! 认证方案: Bearer
|
||||
*/
|
||||
config.headers.Authorization = config.headers.Authorization || 'Bearer ' + token
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function reqReject(error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
export function repResolve(response) {
|
||||
return response?.data
|
||||
}
|
||||
|
||||
export function repReject(error) {
|
||||
let { code, message } = error.response?.data || {}
|
||||
if (isNullOrUndef(code)) {
|
||||
// 未知错误
|
||||
code = -1
|
||||
message = '接口异常!'
|
||||
} else {
|
||||
/**
|
||||
* TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
|
||||
*/
|
||||
switch (code) {
|
||||
case 401:
|
||||
// 未登录(可能是token过期或者无效了)
|
||||
console.error(message)
|
||||
removeToken()
|
||||
const { currentRoute } = router
|
||||
router.replace({
|
||||
path: '/login',
|
||||
query: { ...currentRoute.query, redirect: currentRoute.path },
|
||||
})
|
||||
message = message || '登录已过期'
|
||||
break
|
||||
case 403:
|
||||
// 没有权限
|
||||
console.error(message)
|
||||
message = message || '没有权限'
|
||||
break
|
||||
case 404:
|
||||
// 资源不存在
|
||||
console.error(message)
|
||||
message = message || '资源或接口不存在'
|
||||
break
|
||||
default:
|
||||
message = message || '未知异常'
|
||||
break
|
||||
}
|
||||
// 已知错误resolve,在业务代码中作提醒,未知错误reject,捕获错误统一提示接口异常(9000以上为业务类型错误,需要跟后端确定好)
|
||||
if ([401, 403, 404].includes(code) || code >= 9000) {
|
||||
return Promise.resolve({ code, message })
|
||||
} else {
|
||||
console.error('【err】' + error)
|
||||
return Promise.reject({ message: '接口异常,请稍后重试!' })
|
||||
}
|
||||
}
|
||||
)
|
||||
console.error(`【${code}】 ${error}`)
|
||||
return Promise.resolve({ code, message, error })
|
||||
}
|
||||
|
||||
7
src/utils/icon.js
Normal 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 }) })
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export function isNull(val) {
|
||||
return val === null
|
||||
}
|
||||
|
||||
export function isWhitespace(val) {
|
||||
return val === ''
|
||||
}
|
||||
|
||||
export function isObject(val) {
|
||||
return !isNull(val) && is(val, 'Object')
|
||||
}
|
||||
@@ -64,6 +68,10 @@ export function isNullOrUndef(val) {
|
||||
return isNull(val) || isUndef(val)
|
||||
}
|
||||
|
||||
export function isNullOrWhitespace(val) {
|
||||
return isNullOrUndef(val) || isWhitespace(val)
|
||||
}
|
||||
|
||||
export function isEmpty(val) {
|
||||
if (isArray(val) || isString(val)) {
|
||||
return val.length === 0
|
||||
@@ -81,14 +89,14 @@ export function isEmpty(val) {
|
||||
}
|
||||
|
||||
/**
|
||||
* * 类似sql的isnull函数
|
||||
* * 第一个参数为null/undefined/''则返回第二个参数作为默认值,否则返回第一个参数
|
||||
* * 类似mysql的IFNULL函数
|
||||
* * 第一个参数为null/undefined/'' 则返回第二个参数作为备用值,否则返回第一个参数
|
||||
* @param {Number|Boolean|String} val
|
||||
* @param {Number|Boolean|String} replaceVal
|
||||
* @param {Number|Boolean|String} def
|
||||
* @returns
|
||||
*/
|
||||
export function isNullReplace(val, replaceVal = '') {
|
||||
return isNullOrUndef(val) || val === '' ? replaceVal : val
|
||||
export function ifNull(val, def = '') {
|
||||
return isNullOrWhitespace(val) ? def : val
|
||||
}
|
||||
|
||||
export function isUrl(path) {
|
||||
|
||||
@@ -24,14 +24,12 @@ export async function refreshAccessToken() {
|
||||
return
|
||||
}
|
||||
const { time } = tokenItem
|
||||
if (new Date().getTime() - time > 1000 * 60 * 30) {
|
||||
// token生成或者刷新后30分钟内不执行刷新
|
||||
if (new Date().getTime() - time <= 1000 * 60 * 30) return
|
||||
try {
|
||||
const res = await refreshToken()
|
||||
if (res.code === 0) {
|
||||
setToken(res.data.token)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,52 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-card>
|
||||
<div p-15>
|
||||
<n-card rounded-10>
|
||||
<div flex items-center>
|
||||
<img width="60" style="border-radius: 50%" :src="userStore.avatar" />
|
||||
<div ml20>
|
||||
<img rounded-full width="60" :src="userStore.avatar" />
|
||||
<div ml-20>
|
||||
<p text-16>Hello, {{ userStore.name }}</p>
|
||||
<p op80 text-12 mt5>今天又是元气满满的一天</p>
|
||||
<p mt-5 text-12 op-60>今天又是元气满满的一天</p>
|
||||
</div>
|
||||
<div flex ml-auto>
|
||||
<div ml-auto flex items-center>
|
||||
<n-statistic label="待办" :value="4">
|
||||
<template #suffix> / 10 </template>
|
||||
</n-statistic>
|
||||
<n-statistic ml80 label="Stars">
|
||||
<n-number-animation ref="starsNumberRef" show-separator :from="0" :to="999" />
|
||||
<n-statistic label="Stars" w-100 ml-80>
|
||||
<a href="https://github.com/zclzone/vue-naive-admin">
|
||||
<img allt="stars" src="https://badgen.net/github/stars/zclzone/vue-naive-admin" />
|
||||
</a>
|
||||
</n-statistic>
|
||||
<n-statistic ml80 label="Forks">
|
||||
<n-number-animation ref="starsNumberRef" show-separator :from="0" :to="299" />
|
||||
<n-statistic label="Forks" w-100 ml-80>
|
||||
<a href="https://github.com/zclzone/vue-naive-admin">
|
||||
<img allt="forks" src="https://badgen.net/github/forks/zclzone/vue-naive-admin" />
|
||||
</a>
|
||||
</n-statistic>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<div p15 flex>
|
||||
<n-card title="项目" size="small" :segmented="true">
|
||||
<n-card title="项目" size="small" :segmented="true" mt-15 rounded-10>
|
||||
<template #header-extra>
|
||||
<n-button text type="primary">更多</n-button>
|
||||
</template>
|
||||
<div class="card-list">
|
||||
<n-card v-for="i in 10" :key="i" title="Vue Naive Admin" size="small">
|
||||
<p op60>一个基于 Vue3.0、Vite、Naive UI 的轻量级后台管理模板</p>
|
||||
<div flex flex-wrap justify-between>
|
||||
<n-card
|
||||
v-for="i in 10"
|
||||
:key="i"
|
||||
class="w-300 flex-shrink-0 mt-10 mb-10 cursor-pointer"
|
||||
hover:card-shadow
|
||||
title="Vue Naive Admin"
|
||||
size="small"
|
||||
>
|
||||
<p op-60>一个基于 Vue3.0、Vite、Naive UI 的轻量级后台管理模板</p>
|
||||
</n-card>
|
||||
<div class="blank"></div>
|
||||
<div class="blank"></div>
|
||||
<div class="blank"></div>
|
||||
<div class="blank"></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>
|
||||
|
||||
<script setup>
|
||||
@@ -45,24 +54,3 @@ import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
</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>
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
const { replace } = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-404">
|
||||
<n-result status="404" description="抱歉,您访问的页面不存在。">
|
||||
<template #icon>
|
||||
<img src="@/assets/imgs/404/404.png" width="500" />
|
||||
<img src="@/assets/images/404.png" width="500" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<n-button color="#002d6f" @click="replace('/')">返回首页</n-button>
|
||||
<n-button strong secondary type="primary" @click="replace('/')">返回首页</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { replace } = useRouter()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-404 {
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 60px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||