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

76 Commits

Author SHA1 Message Date
张传龙
841bab0d63 release: release v1.0.0 2022-08-27 14:51:08 +08:00
张传龙
453148fc8d build: update commitlint 2022-08-27 14:50:34 +08:00
张传龙
7ec078bd7a Revert "mod: gh-pages gzip"
This reverts commit dd0bc3e6e8.
2022-08-27 14:47:18 +08:00
张传龙
dd0bc3e6e8 mod: gh-pages gzip 2022-08-27 14:36:21 +08:00
张传龙
8c665c727b style: format 2022-08-27 14:26:14 +08:00
张传龙
da98aa1c7d docs: update readme 2022-08-27 14:23:12 +08:00
张传龙
51b47ea722 chore: upgrade to vite3 2022-08-27 14:22:09 +08:00
张传龙
220a7800f7 refactor: refactor 2022-08-27 14:09:32 +08:00
张传龙
230e3a72d9 chore: update deps 2022-08-27 12:03:58 +08:00
张传龙
0cefadc2a5 refactor: custom icon 2022-08-27 11:46:34 +08:00
张传龙
2f1b747243 feat: add compress plugin 2022-08-27 11:04:07 +08:00
张传龙
296d5ea6f0 perf: png replace with webp 2022-08-27 10:58:22 +08:00
张传龙
3a415703d4 perf: table demo 2022-08-27 10:36:07 +08:00
张传龙
006f730457 perf: table demo 2022-08-26 22:48:03 +08:00
张传龙
606c5a2df0 docs: update readme 2022-08-25 18:26:29 +08:00
张传龙
30c375cc1d fix(components): fix tags contenxtmenu
ISSUES CLOSED: #23
2022-08-18 10:56:28 +08:00
张传龙
ddf14053da chore: update deps 2022-08-18 09:42:08 +08:00
张传龙
38edbcb68a mod: update mock data 2022-08-18 09:39:09 +08:00
Ronnie Zhang
3e54a82abb Merge pull request #22 from amplest/main
fix: get请求无法接收参数
2022-08-12 22:59:43 +08:00
Xiongxing
df6225a752 fix: get请求无法接收参数 2022-08-12 21:10:39 +08:00
张传龙
63c1f2f132 refactor: routes sort 2022-08-08 15:44:16 +08:00
张传龙
0bb2a904e7 refactor: adjust routes 2022-08-08 15:36:43 +08:00
张传龙
ef3aaa5be5 refactor: refactor async routes 2022-08-07 22:25:28 +08:00
张传龙
869a68812c chore: adjust commitlink config 2022-08-04 18:04:08 +08:00
张传龙
fd0032e0e9 docs: update readme 2022-08-02 09:34:25 +08:00
张传龙
b53d7daaa1 docs: update readme 2022-08-02 09:31:17 +08:00
张传龙
856bdfd0ee docs: update readme 2022-07-31 18:08:13 +08:00
张传龙
9f9884759c refactor: simplify permission-guard 2022-07-30 22:11:53 +08:00
张传龙
7dad43d003 docs: update readme 2022-07-29 16:48:58 +08:00
张传龙
7762e02b31 refactor: refactor api usage 2022-07-25 18:36:22 +08:00
张传龙
e5768fa1e3 style: modify login page 2022-07-23 22:17:23 +08:00
张传龙
7ee613d8cf style: modify footer 2022-07-23 22:10:01 +08:00
张传龙
80a5b7f053 style: modify custom scrollbar 2022-07-21 17:47:11 +08:00
张传龙
eb160731da feat: login page compatible mobile 2022-07-20 18:26:38 +08:00
张传龙
789231a7f4 style: format 2022-07-20 09:13:07 +08:00
张传龙
6ea6e1c267 chore: update settings.json 2022-07-20 09:10:40 +08:00
张传龙
d971e7e4ba fix: fix incorrent judgment of isHash 2022-07-19 16:29:07 +08:00
张传龙
215998dc66 perf: optimize login page 2022-07-17 20:37:44 +08:00
张传龙
40f9ac1a6b fix: fix incorrent usage of vue router 2022-07-17 14:54:45 +08:00
张传龙
6ec5588ed4 chore: setup lint-staged 2022-07-15 14:48:40 +08:00
张传龙
380e5768c4 chore: update settings.json 2022-07-15 14:06:57 +08:00
张传龙
5856f601fa style: use unocss rewrite css 2022-07-14 18:05:47 +08:00
张传龙
d10b8f0e96 chore(prettier): update prettier config 2022-07-14 18:04:00 +08:00
张传龙
3860cf9ebb style: simplify unocss test page 2022-07-14 16:40:25 +08:00
张传龙
94b46d9bf6 chore(unocss): update unocss config 2022-07-14 16:39:24 +08:00
张传龙
4df7d44bf1 docs: modify annotation 2022-07-13 22:58:09 +08:00
张传龙
42b8aca37b docs(readme): update readme 2022-07-11 16:07:07 +08:00
张传龙
0c96d0e937 docs(readme): update readme 2022-07-11 12:29:48 +08:00
张传龙
b540f5599f fix: fix incorrect text 2022-07-11 12:28:33 +08:00
张传龙
18b8a81640 fix(other): disabled unplugin generate .d.ts 2022-07-10 22:24:23 +08:00
张传龙
06b3afc2de build(deps): update unplugin deps 2022-07-10 22:20:21 +08:00
92376
3088773ebe fix: modify exit full screen icon
* !1 退出全屏 icon
* 退出全屏 icon
2022-07-10 12:55:45 +00:00
张传龙
83b42bf6b8 chore(projects): add husky and commitlint 2022-07-10 14:02:02 +08:00
张传龙
fd08d25ccf perf: optimize ScrooX component. 2022-07-09 15:03:39 +08:00
张传龙
76c3f0b64c perf: optimize ScrooX component. 2022-07-09 14:38:38 +08:00
张传龙
a1db8273f5 chore: update unocss dependencies. 2022-07-08 22:35:55 +08:00
张传龙
f5ab04112f chore: update settings.json 2022-07-08 17:43:57 +08:00
张传龙
805b2e066f docs: update readme 2022-07-06 10:10:23 +08:00
Ronnie Zhang
6979b245a9 Merge pull request #19 from sean3112/main
微调一下demo
2022-07-05 20:15:27 +08:00
Sean Huang
dff8862c75 feat: Add response code 400.
fix: Change the parameter naming of the get method to params.
2022-07-05 18:35:05 +08:00
Sean Huang
1da5e8d573 Merge remote-tracking branch 'origin/main' 2022-07-05 18:12:01 +08:00
张传龙
7f97dd2f5a style: update prettier format rules 2022-07-03 14:52:49 +08:00
Sean Huang
1f69f07100 Merge remote-tracking branch 'origin/main' 2022-07-03 00:15:38 +08:00
张传龙
f97beeb54b perf: add remember me feature 2022-07-02 00:03:34 +08:00
张传龙
57bc68e7b0 refactor: simplify wrapper storage 2022-07-01 23:27:05 +08:00
Ronnie Zhang
90aa54d4a4 Merge pull request #18 from sean3112/patch-1
Breakpoints issues, depends on 'vite-plugin-vue-setup-extend-plus' instead of 'vite-plugin-vue-setup-extend'
2022-07-01 15:24:32 +08:00
Sean Huang
7564f115d6 Breakpoints issues
Solved the problem that the breakpoint is not in the source code location when debugging.
2022-07-01 15:07:01 +08:00
Sean Huang
8d3753a80e Update package.json
Breakpoints are not in the source code location during debugging
2022-07-01 12:58:43 +08:00
Sean Huang
a816028560 调试时,断点不在源码位置处,更新插件依赖vite-plugin-vue-setup-extend为vite-plugin-vue-setup-extend-plus即可。 2022-07-01 12:38:38 +08:00
张传龙
acde2c1004 feat: Breadcrumb add Icon 2022-06-30 18:29:26 +08:00
张传龙
cb5dd34e17 refactor: simplify mock setup 2022-06-26 18:42:07 +08:00
张传龙
73c82520ca mod: use unocss rewrite the demo page 2022-06-26 18:25:14 +08:00
张传龙
e465ee50bf mod: use unocss rewrite the 404 page 2022-06-26 15:39:44 +08:00
张传龙
2be3f095aa mod: delete extra code 2022-06-26 15:37:57 +08:00
张传龙
26ecafffdc docs: update readme 2022-06-26 15:26:52 +08:00
张传龙
7150d93394 docs: update readme 2022-06-26 15:09:00 +08:00
107 changed files with 4109 additions and 1188 deletions

45
.cz-config.js Normal file
View File

@@ -0,0 +1,45 @@
module.exports = {
types: [
{ value: 'feat', name:'feat: 新增功能' },
{ value: 'fix', name:'fix: 修复bug' },
{ value: 'docs', name:'docs: 文档变更' },
{ value: 'style', name:'style: 代码格式(不影响功能,例如空格、分号等格式修正)' },
{ value: 'refactor', name:'refactor: 代码重构(不包括 bug 修复、功能新增)' },
{ value: 'perf', name:'perf: 性能优化' },
{ value: 'test', name:'test: 添加、修改测试用例' },
{ value: 'build', name:'build: 构建流程、外部依赖变更(如升级 npm 包、修改 脚手架 配置等)' },
{ value: 'ci', name:'ci: 修改 CI 配置、脚本' },
{ value: 'chore', name:'chore: 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)' },
{ value: 'revert', name:'revert: 回滚 commit' },
{ value: 'wip', name:'wip: 开发中' },
{ value: 'mod', name:'mod: 不确定分类的修改' },
{ value: 'release', name:'release: 发布' },
],
scopes: [
['custom', '自定义'],
['projects', '项目搭建'],
['components', '组件相关'],
['utils', 'utils 相关'],
['styles', '样式相关'],
['deps', '项目依赖'],
['other', '其他修改'],
].map(([value, description]) => {
return {
value,
name: `${value.padEnd(30)} (${description})`
}
}),
messages: {
type: '确保本次提交遵循 Angular 规范!选择你要提交的类型:\n',
scope: '选择一个 scope可选',
customScope: '请输入自定义的 scope',
subject: '填写简短精炼的变更描述:',
body: '填写更加详细的变更描述(可选)。使用 "|" 换行:',
breaking: '列举非兼容性重大的变更(可选):',
footer: '列举出所有变更的 Issues Closed可选。 例如: #31, #34',
confirmCommit: '确认提交?'
},
allowBreakingChanges: ['feat', 'fix'],
subjectLimit: 100,
breaklineChar: '|'
}

2
.env
View File

@@ -1,3 +1,3 @@
VITE_APP_TITLE = 'Vue Naive Admin' VITE_TITLE = 'Vue Naive Admin'
VITE_PORT = 3100 VITE_PORT = 3100

View File

@@ -2,13 +2,13 @@
VITE_PUBLIC_PATH = '/' VITE_PUBLIC_PATH = '/'
# 是否启用MOCK # 是否启用MOCK
VITE_APP_USE_MOCK = true VITE_USE_MOCK = true
# proxy # 是否启用MOCK
VITE_PROXY = [["/api","http://localhost:8080"],["/api-test","localhost:8080"]] VITE_USE_PROXY = false
# 代理类型(跟启动和构建环境无关) 'dev' | 'test' | 'prod'
VITE_PROXY_TYPE = 'dev'
# base api # base api
VITE_APP_BASE_API = '/api' VITE_BASE_API = '/api'
# test base api
VITE_APP_BASE_API_TEST = '/api-test'

View File

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

View File

@@ -2,10 +2,13 @@
VITE_PUBLIC_PATH = '/' VITE_PUBLIC_PATH = '/'
# 是否启用MOCK # 是否启用MOCK
VITE_APP_USE_MOCK = true VITE_USE_MOCK = true
# base api # base api
VITE_APP_BASE_API = '/api' VITE_BASE_API = '/api'
# test base api # 是否启用压缩
VITE_APP_BASE_API_TEST = '/api-test' VITE_USE_COMPRESS = true
# 压缩类型
VITE_COMPRESS_TYPE = gzip

View File

@@ -1,10 +1,7 @@
VITE_PUBLIC_PATH = '/' VITE_PUBLIC_PATH = '/'
# 是否启用MOCK # 是否启用MOCK
VITE_APP_USE_MOCK = true VITE_USE_MOCK = true
# base api # base api
VITE_APP_BASE_API = '/api' VITE_BASE_API = '/api'
# test base api
VITE_APP_BASE_API_TEST = '/api-test'

View File

@@ -2,7 +2,7 @@ module.exports = {
root: true, root: true,
extends: ['plugin:vue/vue3-recommended', 'plugin:prettier/recommended'], extends: ['plugin:vue/vue3-recommended', 'plugin:prettier/recommended'],
rules: { rules: {
'prettier/prettier': 'warn', 'prettier/prettier': 'error',
'vue/valid-template-root': 'off', 'vue/valid-template-root': 'off',
'vue/no-multiple-template-root': 'off', 'vue/no-multiple-template-root': 'off',
'vue/multi-word-component-names': [ 'vue/multi-word-component-names': [

36
.husky/_/husky.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
readonly husky_skip_init=1
export husky_skip_init
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
if [ $exitCode = 127 ]; then
echo "husky - command not found in PATH=$PATH"
fi
exit $exitCode
fi

4
.husky/commit-msg Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install commitlint --edit "$1"

4
.husky/pre-commit Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint:staged

30
.vscode/settings.json vendored
View File

@@ -1,12 +1,34 @@
{ {
"files.eol": "\n",
"path-intellisense.mappings": { "path-intellisense.mappings": {
"@/": "${workspaceRoot}/src" "@/": "${workspaceRoot}/src",
"~/": "${workspaceRoot}"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"prettier.printWidth": 120,
"prettier.singleQuote": true,
"prettier.semi": false,
"prettier.endOfLine": "lf",
"files.eol": "\n",
"[javascript]": {
"editor.formatOnSave": false
},
"[typescript]": {
"editor.formatOnSave": false
},
"[typescriptreact]": {
"editor.formatOnSave": false
},
"[vue]": {
"editor.formatOnSave": false
}, },
"editor.formatOnSave": false,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"], "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": true
}, },
"cssrem.rootFontSize": 4, // 适配unocss1rem = 4px ==> 0.25rem = 1px "files.associations": {
"*.env.*": "dotenv",
"*.css": "postcss"
}
} }

129
README.md
View File

@@ -10,113 +10,106 @@
<a href="./LICENSE"><img allt="MIT License" src="https://badgen.net/github/license/zclzone/vue-naive-admin"/></a> <a href="./LICENSE"><img allt="MIT License" src="https://badgen.net/github/license/zclzone/vue-naive-admin"/></a>
</p> </p>
<p align='center'>
<b>English</b> |
<a href="https://github.com/zclzone/vue-naive-admin/blob/main/README.zh-CN.md">简体中文</a>
</p>
### 简介 ### Introduction
[Vue Naive Admin](https://github.com/zclzone/vue-naive-admin),一个基于 Vue3.0、Vite、Naive UI 的后台管理模板相较于其他比较流行的后台管理模板此项目相对简洁、轻量学习成本非常低对新手极其友好。不过麻雀虽小五脏俱全权限、Mock、菜单、axios 封装、pinia、项目配置、样式配置、环境配置以及一些经常用的基础组件封装等等这些该有的都有非常适用于中小型项目或者个人项目也可此模板进行二次封装改造用于大型项目。 [Vue Naive Admin](https://github.com/zclzone/vue-naive-admin) is a **completely open source free and commercially allowed ** admin templateBased on the latest technology stack of front-end such as `Vue3、Vite3、Pinia、Unocss and Naive UI`. Compared with other more popular backend management templates, this project is more concise, lightweight, fresh style, very low learning costs, ideal for small and medium-sized projects or personal projects.
### 为什么要开发这个模板 ### Features
- Vue3 和 Vite 已经趋于成熟,学习 vite 和 vue3 非常有必要,通过开发模板进行学习是一个很好的方式,事实也证明我确实从中获益良多 - 🍒 Integrated [Naive UI](https://www.naiveui.com)recommended by Evan You.
- 目前主流的 Vue3+Vite 后台管理模板都相对复杂,甚至感觉有点花里胡哨(没有贬低的意思,大部分的架构设计都很优秀,只是觉得集成了太多不实用的东西) - 🍑 Integrated login, logout and permission verification.
- 🍐 Integrated multi-environment configuration, dev, test, production and github pages environments.
- 🍎 Integrated `eslint + prettier`.
- 🍌 Integrated `husky + commitlint`.
- 🍉 Integrated `Mock`.
- 🍍 Integrated `pinia`lightweight, simple and easy to use alternative to vuex.
- 📦 Integrated `unplugin` auto import.
- 🤹 Integrated `iconify` iconsupport custom svg icons.
- 🍇 Integrated `unocss`.
### 功能 ### Preview
- 🍒 集成 Naive UI尤大推荐的 UI 组件库,[https://www.naiveui.com](https://www.naiveui.com) [https://template.qszone.com](https://template.qszone.com)
- 🍑 集成登陆、注销及权限验证
- 🍐 集成多环境配置dev、测试、生产和github pages环境
- 🍎 集成 Eslint + Prettier代码约束和格式化统一
- 🍉 集成 Mock 接口服务dev 环境和发布环境都支持,可动态配置是否启用 mock 服务,不启用时不会加载 mock 包,减少打包体积
- 🍇 集成 unocssantfu 大神开源的原子化 css 解决方案,非常轻量,目前我是自己写 scss 样式搭配着 unocss 使用的
- 🍍 集成 PiniaVuex 的替代方案轻量、简单、易用尤大已表示不会有Vuex5或者说pinia就是Vuex5
- 📦 集成 Vite 自动导入插件unplugin-vue-components解放双手开发效率直接起飞
- 🤹 集成 unplugin-icons插件优雅使用iconify图标
- 🍏 二次封装 Axios支持多 axios 实例
- 🍌 二次封装全局 Dialog、Message、LoadingBar 组件
- 🍋 二次封装 localStorage 和 sessionStorage支持设置过期时间
### 预览 [https://zclzone.github.io/vue-naive-admin](https://zclzone.github.io/vue-naive-admin)
[template.qszone.com](https://template.qszone.com) ### Docs
[github pages](https://zclzone.github.io/vue-naive-admin)
### 文档
[Vue Naive Admin Docs](https://zclzone.github.io/vue-naive-admin-docs) [Vue Naive Admin Docs](https://zclzone.github.io/vue-naive-admin-docs)
[羽雀文档Vue Naive Admin](https://www.yuque.com/qszone/vue-naive-admin)
### 构建 ### Getting Started
```shell ```shell
# 推荐配置git autocrlf 为 false本项目规范使用lf换行符此配置是为防止git自动将源文件转换为crlf # Recommended setup git autocrlf 为 false
# 不清楚为什么要这样做的请参考这篇文章https://www.freesion.com/article/4532642129
git config --global core.autocrlf false git config --global core.autocrlf false
# 克隆项目 # Clone Project
git clone https://github.com/zclzone/vue-naive-admin.git git clone https://github.com/zclzone/vue-naive-admin.git
# 进入项目目录
cd vue-naive-admin cd vue-naive-admin
# 安装依赖(建议使用pnpm: https://pnpm.io/zh/installation) # Install dependencies(Recommended use pnpm: https://pnpm.io/zh/installation)
pnpm i # 或者 npm i npm i -g pnpm # Installed and can be ignored
pnpm i # or npm i
# 启动 # Start
npm run dev pnpm dev
``` ```
### 发布 ### Build and Release
```shell ```shell
# 构建测试环境 # Test Environment
npm run build:test pnpm build:test
# 构建github pages环境 # Github Environment
npm run build:github pnpm build:github
# 构建生产环境 # Prod Environment
npm run build pnpm build
``` ```
### 其他指令 ### Other
```shell ```shell
# eslint代码格式检查 # eslint check
npm run lint pnpm lint
# 代码检查并修复 # eslint check and fix
npm run lint:fix pnpm lint:fix
# 预览发布包效果(需先执行构建指令 # PreviewNeed to build first
npm run preview pnpm preview
# Commithusky+commitlint
pnpm cz
``` ```
### 规范 ### TS version: Qs Admin
#### git commit 规范 #### source code
- `feat` 增加新功能 - gitub: [https://github.com/zclzone/qs-admin](https://github.com/zclzone/qs-admin)
- `fix` 修复问题/BUG - gitee: [https://gitee.com/zclzone/qs-admin-ts](https://gitee.com/zclzone/qs-admin-ts)
- `style` 代码风格相关无影响运行结果的
- `perf` 优化/性能提升 #### preview
- `refactor` 重构
- `revert` 撤销修改 - [https://admin.qszone.com](https://admin.qszone.com)
- `test` 测试相关 - [https://zclzone.github.io/qs-admin](https://zclzone.github.io/qs-admin)
- `docs` 文档/注释
- `chore` 依赖更新/脚手架配置修改等 ### Communication group & About the author
- `workflow` 工作流改进
- `ci` 持续集成 <a href="https://blog.qszone.com/about/">
- `types` 类型定义文件更改 <img src="https://assets.qszone.com/images/about.png" style="max-width: 400px" />
- `wip` 开发中 </a>
- `mod` 不确定分类的修改
- `release` 发布
<p align="center">
<img src="https://assets.qszone.com/image/Snipaste_2022-06-23_19-26-26.png" />
</p>

115
README.zh-CN.md Normal file
View File

@@ -0,0 +1,115 @@
<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>
<p align='center'>
<b>简体中文</b> |
<a href="https://github.com/zclzone/vue-naive-admin/blob/main/README.md">English</a>
</p>
### 简介
[Vue Naive Admin](https://github.com/zclzone/vue-naive-admin) 是一个 **完全开源免费且允许商用** 的后台管理模板,基于 `Vue3、Vite3、Pinia、Unocss 和 Naive UI` 等前端最新技术栈。相较于其他比较流行的后台管理模板,此项目更加简洁、轻量,风格清新,学习成本非常低,非常适合中小型项目或者个人项目。
### 功能
- 🍒 集成 [Naive UI](https://www.naiveui.com)
- 🍑 集成登陆、注销及权限验证
- 🍐 集成多环境配置dev、测试、生产和github pages环境
- 🍎 集成 `eslint + prettier`,代码约束和格式化统一
- 🍌 集成 `husky + commitlint`,代码提交规范化
- 🍉 集成 `mock` 接口服务dev 环境和发布环境都支持,可动态配置是否启用 mock 服务,不启用时不会加载 mock 包,减少打包体积
- 🍍 集成 `pinia`vuex 的替代方案,轻量、简单、易用
- 📦 集成 `unplugin` 插件,自动导入,解放双手,开发效率直接起飞
- 🤹 集成 `iconify` 图标,支持自定义 svg 图标, 优雅使用icon
- 🍇 集成 `unocss`antfu 开源的原子 css 解决方案,非常轻量
### 预览
[https://template.qszone.com](https://template.qszone.com)
[https://zclzone.github.io/vue-naive-admin](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
# 不清楚为什么要这样做的请参考这篇文章https://www.freesion.com/article/4532642129
git config --global core.autocrlf false
# 克隆项目
git clone https://github.com/zclzone/vue-naive-admin.git
# 进入项目目录
cd vue-naive-admin
# 安装依赖(建议使用pnpm: https://pnpm.io/zh/installation)
npm i -g pnpm # 装了可忽略
pnpm i # 或者 npm i
# 启动
pnpm dev
```
### 构建发布
```shell
# 构建测试环境
pnpm build:test
# 构建github pages环境
pnpm build:github
# 构建生产环境
pnpm build
```
### 其他指令
```shell
# eslint代码格式检查
pnpm lint
# 代码检查并修复
pnpm lint:fix
# 预览发布包效果(需先执行构建指令)
pnpm preview
# 提交代码husky+commitlint
pnpm cz
```
### TS 版本: Qs Admin
#### 源码
- gitub: [https://github.com/zclzone/qs-admin](https://github.com/zclzone/qs-admin)
- gitee: [https://gitee.com/zclzone/qs-admin-ts](https://gitee.com/zclzone/qs-admin-ts)
#### 预览
- [https://admin.qszone.com](https://admin.qszone.com)
- [https://zclzone.github.io/qs-admin](https://zclzone.github.io/qs-admin)
### 入群交流 & 关于作者
<a href="https://blog.qszone.com/about/">
<img src="https://assets.qszone.com/images/about.png" style="max-width: 400px" />
</a>

13
build/config/define.js Normal file
View File

@@ -0,0 +1,13 @@
import dayjs from 'dayjs'
/**
* * 此处定义的是全局常量启动或打包后将添加到window中
* https://vitejs.cn/config/#define
*/
// 项目构建时间
const _BUILD_TIME_ = JSON.stringify(dayjs().format('YYYY-MM-DD HH:mm:ss'))
export const viteDefine = {
_BUILD_TIME_,
}

2
build/config/index.js Normal file
View File

@@ -0,0 +1,2 @@
export * from './define'
export * from './proxy'

15
build/config/proxy.js Normal file
View File

@@ -0,0 +1,15 @@
import { getProxyConfig } from '../../settings'
export function createViteProxy(isUseProxy = true, proxyType) {
if (!isUseProxy) return undefined
const proxyConfig = getProxyConfig(proxyType)
const proxy = {
[proxyConfig.prefix]: {
target: proxyConfig.target,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${proxyConfig.prefix}`), ''),
},
}
return proxy
}

View File

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

View File

@@ -4,7 +4,7 @@ import vue from '@vitejs/plugin-vue'
* * 扩展setup插件支持在script标签中使用name属性 * * 扩展setup插件支持在script标签中使用name属性
* usage: <script setup name="MyComp"></script> * usage: <script setup name="MyComp"></script>
*/ */
import VueSetupExtend from 'vite-plugin-vue-setup-extend' import vueSetupExtend from 'vite-plugin-vue-setup-extend-plus'
/** /**
* * unocss插件原子css * * unocss插件原子css
@@ -14,18 +14,24 @@ import Unocss from 'unocss/vite'
// rollup打包分析插件 // rollup打包分析插件
import visualizer from 'rollup-plugin-visualizer' import visualizer from 'rollup-plugin-visualizer'
// 压缩
import viteCompression from 'vite-plugin-compression'
import { configHtmlPlugin } from './html' import { configHtmlPlugin } from './html'
import { configMockPlugin } from './mock' import { configMockPlugin } from './mock'
import unplugin from './unplugin' import unplugin from './unplugin'
export function createVitePlugins(viteEnv, isBuild) { export function createVitePlugins(viteEnv, isBuild) {
const plugins = [vue(), VueSetupExtend(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()] const plugins = [vue(), vueSetupExtend(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
if (viteEnv?.VITE_APP_USE_MOCK) { if (viteEnv?.VITE_USE_MOCK) {
plugins.push(configMockPlugin(isBuild)) plugins.push(configMockPlugin(isBuild))
} }
if (viteEnv.VITE_USE_COMPRESS) {
plugins.push(viteCompression({ algorithm: viteEnv.VITE_COMPRESS_TYPE || 'gzip' }))
}
if (isBuild) { if (isBuild) {
plugins.push( plugins.push(
visualizer({ visualizer({

View File

@@ -2,12 +2,11 @@ import { viteMockServe } from 'vite-plugin-mock'
export function configMockPlugin(isBuild) { export function configMockPlugin(isBuild) {
return viteMockServe({ return viteMockServe({
ignore: /^\_/, mockPath: 'mock/api',
mockPath: 'mock',
localEnabled: !isBuild, localEnabled: !isBuild,
prodEnabled: isBuild, prodEnabled: isBuild,
injectCode: ` injectCode: `
import { setupProdMockServer } from '../mock/_create-prod-server'; import { setupProdMockServer } from '../mock';
setupProdMockServer(); setupProdMockServer();
`, `,
}) })

View File

@@ -1,3 +1,4 @@
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
@@ -10,13 +11,16 @@ import IconsResolver from 'unplugin-icons/resolver'
* 图标库: https://icones.js.org/ * 图标库: https://icones.js.org/
*/ */
import Icons from 'unplugin-icons/vite' import Icons from 'unplugin-icons/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import { getRootPath } from '../utils' import { getSrcPath } from '../utils'
const customIconPath = resolve(getSrcPath(), 'assets/svg')
const customIconPath = getRootPath('src', 'assets/icons')
export default [ export default [
AutoImport({ AutoImport({
imports: ['vue', 'vue-router'], imports: ['vue', 'vue-router'],
dts: false,
}), }),
Icons({ Icons({
compiler: 'vue3', compiler: 'vue3',
@@ -28,5 +32,12 @@ export default [
}), }),
Components({ Components({
resolvers: [NaiveUiResolver(), IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' })], resolvers: [NaiveUiResolver(), IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' })],
dts: false,
}),
createSvgIconsPlugin({
iconDirs: [customIconPath],
symbolId: 'icon-custom-[dir]-[name]',
inject: 'body-last',
customDomId: '__CUSTOM_SVG_ICON__',
}), }),
] ]

View File

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

View File

@@ -2,53 +2,38 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import dotenv from 'dotenv' import dotenv from 'dotenv'
/**
* * 项目根路径
* @descrition 结尾不带/
*/
export function getRootPath() {
return path.resolve(process.cwd())
}
/**
* * 项目src路径
* @param srcName src目录名称(默认: "src")
* @descrition 结尾不带斜杠
*/
export function getSrcPath(srcName = 'src') {
return path.resolve(getRootPath(), srcName)
}
const httpsReg = /^https:\/\// const httpsReg = /^https:\/\//
export function wrapperEnv(envOptions) { export function convertEnv(envOptions) {
if (!envOptions) return {} const result = {}
const ret = {} if (!envOptions) return result
for (const key in envOptions) { for (const envKey in envOptions) {
let val = envOptions[key] let envVal = envOptions[envKey]
if (['true', 'false'].includes(val)) { if (['true', 'false'].includes(envVal)) envVal = envVal === 'true'
val = val === 'true'
}
if (['VITE_PORT'].includes(key)) {
val = +val
}
if (key === 'VITE_PROXY' && val && typeof val === 'string') {
try {
val = JSON.parse(val.replace(/'/g, '"'))
} catch (error) {
val = ''
}
}
ret[key] = val
if (typeof val === 'string') {
process.env[key] = val
} else if (typeof val === 'object') {
process.env[key] = JSON.stringify(val)
}
}
return ret
}
export function createProxy(list = []) { if (['VITE_PORT'].includes(envKey)) envVal = +envVal
const ret = {}
for (const [prefix, target] of list) {
const isHttps = httpsReg.test(target)
// https://github.com/http-party/node-http-proxy#options result[envKey] = envVal
ret[prefix] = {
target: target,
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''),
// https is require secure=false
...(isHttps ? { secure: false } : {}),
} }
} return result
return ret
} }
/** /**
@@ -65,7 +50,7 @@ function getConfFiles() {
return ['.env', '.env.local', '.env.production'] return ['.env', '.env.local', '.env.production']
} }
export function getEnvConfig(match = 'VITE_APP_GLOB_', confFiles = getConfFiles()) { export function getEnvConfig(match = 'VITE_', confFiles = getConfFiles()) {
let envConfig = {} let envConfig = {}
confFiles.forEach((item) => { confFiles.forEach((item) => {
try { try {
@@ -85,7 +70,3 @@ export function getEnvConfig(match = 'VITE_APP_GLOB_', confFiles = getConfFiles(
}) })
return envConfig return envConfig
} }
export function getRootPath(...dir) {
return path.resolve(process.cwd(), ...dir)
}

26
commitlint.config.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
ignores: [(commit) => commit.includes('init')],
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert',
'wip',
'mod',
'release',
],
],
},
}

View File

@@ -15,7 +15,6 @@
<body> <body>
<div id="app"> <div id="app">
<!-- 白屏时的loading效果 --> <!-- 白屏时的loading效果 -->
<div class="loading-container"> <div class="loading-container">
<div id="loadingLogo" class="loading-svg"></div> <div id="loadingLogo" class="loading-svg"></div>
@@ -30,7 +29,6 @@
<div class="loading-title"><%= title %></div> <div class="loading-title"><%= title %></div>
</div> </div>
<script src="/resource/loading.js"></script> <script src="/resource/loading.js"></script>
</div> </div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>

View File

@@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"~/*": ["./*"],
"@/*": ["src/*"] "@/*": ["src/*"]
}, },
"jsx": "preserve" "jsx": "preserve"

View File

@@ -1,14 +0,0 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
const modules = import.meta.globEager('./**/*.js')
const mockModules = []
Object.keys(modules).forEach((key) => {
if (key.includes('/_')) {
return
}
mockModules.push(...modules[key].default)
})
export function setupProdMockServer() {
createProdMockServer(mockModules)
}

View File

@@ -1,4 +1,4 @@
import { resolveToken } from '../_utils' import { resolveToken } from '../utils'
const token = { const token = {
admin: 'admin', admin: 'admin',

5
mock/api/index.js Normal file
View File

@@ -0,0 +1,5 @@
import auth from './auth'
import user from './user'
import post from './post'
export default [...auth, ...user, ...post]

View File

@@ -34,7 +34,7 @@ export default [
{ {
id: 28, id: 28,
title: '如何优雅的给图片添加水印', title: '如何优雅的给图片添加水印',
author: '张传龙', author: '大脸怪',
category: 'JavaScript', category: 'JavaScript',
description: '优雅的给图片添加水印', description: '优雅的给图片添加水印',
content: '我之前写过一篇文章记录了一次上传图片的优化史', content: '我之前写过一篇文章记录了一次上传图片的优化史',
@@ -47,7 +47,7 @@ export default [
{ {
id: 26, id: 26,
title: '前端缓存的理解', title: '前端缓存的理解',
author: '张传龙', author: '大脸怪',
category: 'Http', category: 'Http',
description: '谈谈前端缓存的理解', description: '谈谈前端缓存的理解',
content: '> 背景\n\n公司有个vue-cli3移动端web项目发版更新后发现部分用户手机在钉钉内置浏览器打开出现了缓存', content: '> 背景\n\n公司有个vue-cli3移动端web项目发版更新后发现部分用户手机在钉钉内置浏览器打开出现了缓存',
@@ -56,22 +56,10 @@ export default [
createDate: '2021-06-10T18:51:19.000Z', createDate: '2021-06-10T18:51:19.000Z',
updateDate: '2021-09-17T09:33:24.000Z', updateDate: '2021-09-17T09:33:24.000Z',
}, },
{
id: 24,
title: '使用jQuery的load方法帮女朋友实现套娃Html',
author: '张传龙',
category: 'JavaScript',
description: '最近女朋友刚入职新公司,接到的第一个任务就是将一个网站所有的页面合并成一个页面',
content: '最近女朋友刚入职新公司,接到的第一个任务就是将一个网站所有的页面合并成一个页面',
isRecommend: true,
isPublish: true,
createDate: '2021-05-26T15:26:06.000Z',
updateDate: '2021-09-17T09:33:24.000Z',
},
{ {
id: 18, id: 18,
title: 'Promise的五个静态方法', title: 'Promise的五个静态方法',
author: '张传龙', author: '大脸怪',
category: 'JavaScript', category: 'JavaScript',
description: '简单介绍下在 Promise 类中有5 种静态方法及它们的使用场景', description: '简单介绍下在 Promise 类中有5 种静态方法及它们的使用场景',
content: '## 1. Promise.all\n\n并行执行多个 promise并等待所有 promise 都准备就绪。再对它们进行处理。', content: '## 1. Promise.all\n\n并行执行多个 promise并等待所有 promise 都准备就绪。再对它们进行处理。',

View File

@@ -1,4 +1,4 @@
import { resolveToken } from '../_utils' import { resolveToken } from '../utils'
const users = { const users = {
admin: { admin: {

6
mock/index.js Normal file
View File

@@ -0,0 +1,6 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
import api from './api'
export function setupProdMockServer() {
createProdMockServer(api)
}

View File

@@ -1,19 +1,23 @@
{ {
"name": "vue-naive-admin", "name": "vue-naive-admin",
"version": "0.4.0", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"lint": "eslint --ext .js,.vue .", "lint": "eslint --ext .js,.vue .",
"lint:fix": "eslint --fix --ext .js,.vue .", "lint:fix": "eslint --fix --ext .js,.vue .",
"lint:staged": "lint-staged",
"build": "vite build", "build": "vite build",
"build:test": "vite build --mode test", "build:test": "vite build --mode test",
"build:github": "vite build --mode github && esno ./build/script", "build:github": "vite build --mode github && esno ./build/script",
"preview": "vite preview" "preview": "vite preview",
"prepare": "husky install",
"cz": "cz"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^8.4.2", "@vueuse/core": "^8.4.2",
"axios": "^0.21.4", "axios": "^0.21.4",
"dayjs": "^1.11.0", "dayjs": "^1.11.0",
"lodash-es": "^4.17.21",
"md-editor-v3": "^1.11.4", "md-editor-v3": "^1.11.4",
"mockjs": "^1.1.0", "mockjs": "^1.1.0",
"pinia": "^2.0.13", "pinia": "^2.0.13",
@@ -21,11 +25,16 @@
"vue-router": "^4.0.15" "vue-router": "^4.0.15"
}, },
"devDependencies": { "devDependencies": {
"@iconify/json": "^2.1.63", "@commitlint/cli": "^17.0.3",
"@commitlint/config-conventional": "^17.0.3",
"@iconify/json": "^2.1.99",
"@iconify/vue": "^3.2.1", "@iconify/vue": "^3.2.1",
"@vitejs/plugin-vue": "^1.10.2", "@vitejs/plugin-vue": "^1.10.2",
"@vue/compiler-sfc": "^3.2.31", "@vue/compiler-sfc": "^3.2.31",
"chalk": "^5.0.1", "chalk": "^5.0.1",
"commitizen": "^4.2.4",
"cz-conventional-changelog": "^3.3.0",
"cz-customizable": "^6.9.0",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"eslint": "^8.12.0", "eslint": "^8.12.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
@@ -33,17 +42,31 @@
"eslint-plugin-vue": "^8.5.0", "eslint-plugin-vue": "^8.5.0",
"esno": "^0.13.0", "esno": "^0.13.0",
"fs-extra": "^10.0.1", "fs-extra": "^10.0.1",
"naive-ui": "^2.30.3", "husky": "^8.0.1",
"lint-staged": "^13.0.3",
"naive-ui": "^2.32.1",
"prettier": "^2.6.1", "prettier": "^2.6.1",
"rollup-plugin-visualizer": "^5.6.0", "rollup-plugin-visualizer": "^5.6.0",
"sass": "^1.49.10", "sass": "^1.49.10",
"unocss": "^0.38.2", "unocss": "^0.43.2",
"unplugin-auto-import": "^0.8.8", "unplugin-auto-import": "^0.9.2",
"unplugin-icons": "^0.14.1", "unplugin-icons": "^0.14.1",
"unplugin-vue-components": "^0.17.21", "unplugin-vue-components": "^0.17.21",
"vite": "^2.9.9", "vite": "^3.0.9",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.0", "vite-plugin-html": "^3.2.0",
"vite-plugin-mock": "^2.9.6", "vite-plugin-mock": "^2.9.6",
"vite-plugin-vue-setup-extend": "^0.3.0" "vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-setup-extend-plus": "^0.1.0"
},
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
}
},
"lint-staged": {
"*.{js,vue}": [
"eslint --ext .js,.vue ."
]
} }
} }

3283
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@
.loading-svg { .loading-svg {
width: 128px; width: 128px;
height: 128px; height: 128px;
color: var(--primaryColor); color: var(--primary-color);
} }
.loading-spin__container { .loading-spin__container {
@@ -45,7 +45,7 @@
position: absolute; position: absolute;
height: 16px; height: 16px;
width: 16px; width: 16px;
background-color: var(--primaryColor); background-color: var(--primary-color);
border-radius: 8px; border-radius: 8px;
-webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; -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; animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;

View File

@@ -3,23 +3,23 @@
* @param {string} id - 元素id * @param {string} id - 元素id
*/ */
function initSvgLogo(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 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 appEl = document.querySelector(id)
const div = document.createElement('div'); const div = document.createElement('div')
div.innerHTML = svgStr; div.innerHTML = svgStr
if (appEl) { if (appEl) {
appEl.appendChild(div); appEl.appendChild(div)
} }
} }
function addThemeColorCssVars() { function addThemeColorCssVars() {
const key = '__THEME_COLOR__' const key = '__THEME_COLOR__'
const defaultColor = '#316c72'; const defaultColor = '#316c72'
const themeColor = window.localStorage.getItem(key) || defaultColor; const themeColor = window.localStorage.getItem(key) || defaultColor
const cssVars = `--primaryColor: ${themeColor}`; const cssVars = `--primary-color: ${themeColor}`
document.documentElement.style.cssText = cssVars; document.documentElement.style.cssText = cssVars
} }
addThemeColorCssVars(); addThemeColorCssVars()
initSvgLogo('#loadingLogo'); initSvgLogo('#loadingLogo')

2
settings/index.js Normal file
View File

@@ -0,0 +1,2 @@
export * from './theme.json'
export * from './proxy-config'

18
settings/proxy-config.js Normal file
View File

@@ -0,0 +1,18 @@
const proxyConfigMappings = {
dev: {
prefix: '/api',
target: 'http://localhost:8080',
},
test: {
prefix: '/api',
target: 'http://localhost:8080',
},
prod: {
prefix: '/api',
target: 'http://localhost:8080',
},
}
export function getProxyConfig(envType = 'dev') {
return proxyConfigMappings[envType]
}

View File

@@ -1,11 +1,11 @@
{ {
"header": {
"height": 60
},
"tags": { "tags": {
"visible": true, "visible": true,
"height": 50 "height": 50
}, },
"header": {
"height": 60
},
"naiveThemeOverrides": { "naiveThemeOverrides": {
"common": { "common": {
"primaryColor": "#316C72FF", "primaryColor": "#316C72FF",

View File

@@ -9,12 +9,3 @@
<script setup> <script setup>
import AppProvider from '@/components/common/AppProvider.vue' import AppProvider from '@/components/common/AppProvider.vue'
</script> </script>
<style lang="scss">
#app {
height: 100%;
.n-config-provider {
height: inherit;
}
}
</style>

View File

@@ -1,16 +0,0 @@
import { defAxios as request } from '@/utils/http'
export const login = (data) => {
return request({
url: '/auth/login',
method: 'post',
data,
})
}
export const refreshToken = () => {
return request({
url: '/auth/refreshToken',
method: 'post',
})
}

6
src/api/index.js Normal file
View File

@@ -0,0 +1,6 @@
import request from '@/utils/http'
export default {
getUser: () => request.get('/user'),
refreshToken: () => request.post('/auth/refreshToken'),
}

View File

@@ -1,39 +0,0 @@
import { defAxios as request } from '@/utils/http'
export function getPosts(data = {}) {
return request({
url: '/posts',
method: 'get',
data,
})
}
export function getPostById({ id }) {
return request({
url: `/post/${id}`,
method: 'get',
})
}
export function savePost(id, data = {}) {
if (id) {
return request({
url: `/post/${id}`,
method: 'put',
data,
})
}
return request({
url: '/post',
method: 'post',
data,
})
}
export function deletePost(id) {
return request({
url: `/post/${id}`,
method: 'delete',
})
}

View File

@@ -1,38 +0,0 @@
import { defAxios as request } from '@/utils/http'
export function getUsers(data = {}) {
return request({
url: '/users',
method: 'get',
data,
})
}
export function getUser(id) {
if (id) {
return request({
url: `/user/${id}`,
method: 'get',
})
}
return request({
url: '/user',
method: 'get',
})
}
export function saveUser(data = {}, id) {
if (id) {
return request({
url: '/user',
method: 'put',
data,
})
}
return request({
url: `/user/${id}`,
method: 'put',
data,
})
}

View File

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

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

1
src/assets/svg/logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 811 B

View File

@@ -0,0 +1,13 @@
<template>
<footer text-14 f-c-c flex-col color="#6a6a6a">
<p>
Copyright©2022
<a href="https://github.com/zclzone" target="__blank" hover="decoration-underline color-primary"> 大脸怪</a>
</p>
<p>
<a href="http://beian.miit.gov.cn/" target="__blank" hover="decoration-underline color-primary">
赣ICP备2020015008号-1
</a>
</p>
</footer>
</template>

View File

@@ -1,5 +1,5 @@
<template> <template>
<n-config-provider :theme-overrides="themStore.naiveThemeOverrides"> <n-config-provider wh-full :theme-overrides="naiveThemeOverrides">
<n-loading-bar-provider> <n-loading-bar-provider>
<n-dialog-provider> <n-dialog-provider>
<n-notification-provider> <n-notification-provider>
@@ -16,24 +16,18 @@
<script setup> <script setup>
import { defineComponent, h } from 'vue' import { defineComponent, h } from 'vue'
import { useLoadingBar, useDialog, useMessage, useNotification } from 'naive-ui' import { useLoadingBar, useDialog, useMessage, useNotification } from 'naive-ui'
import { useCssVar } from '@vueuse/core' import { useCssVar } from '@vueuse/core'
import { useThemeStore } from '@/store/modules/theme' import { kebabCase } from 'lodash-es'
import { setupMessage, setupDialog } from '@/utils/common/naiveTools' import { setupMessage, setupDialog } from '@/utils/common/naiveTools'
import { naiveThemeOverrides } from '~/settings'
const themStore = useThemeStore() function setupCssVar() {
watch( const common = naiveThemeOverrides.common
() => themStore.naiveThemeOverrides.common, for (const key in common) {
(vars) => { useCssVar(`--${kebabCase(key)}`, document.documentElement).value = common[key] || ''
for (const key in vars) { if (key === 'primaryColor') window.localStorage.setItem('__THEME_COLOR__', common[key] || '')
useCssVar(`--${key}`, document.documentElement).value = vars[key]
if (key === 'primaryColor') {
window.localStorage.setItem('__THEME_COLOR__', vars[key])
} }
} }
},
{ immediate: true }
)
// 挂载naive组件的方法至window, 以便在全局使用 // 挂载naive组件的方法至window, 以便在全局使用
function setupNaiveTools() { function setupNaiveTools() {
@@ -46,6 +40,7 @@ function setupNaiveTools() {
const NaiveProviderContent = defineComponent({ const NaiveProviderContent = defineComponent({
setup() { setup() {
setupCssVar()
setupNaiveTools() setupNaiveTools()
}, },
render() { render() {

View File

@@ -1,20 +1,19 @@
<template> <template>
<div ref="wrapper" class="tags-wrapper" @mousewheel.prevent="handleMouseWheel"> <div ref="wrapper" class="wrapper" @mousewheel.prevent="handleMouseWheel">
<template v-if="showArrow && isOverflow"> <template v-if="showArrow && isOverflow">
<div class="left" @click="handleMouseWheel({ wheelDelta: 50 })"> <div class="left" @click="handleMouseWheel({ wheelDelta: 120 })">
<icon-ic:baseline-keyboard-arrow-left /> <icon-ic:baseline-keyboard-arrow-left />
</div> </div>
<div class="right" @click="handleMouseWheel({ wheelDelta: -50 })"> <div class="right" @click="handleMouseWheel({ wheelDelta: -120 })">
<icon-ic:baseline-keyboard-arrow-right /> <icon-ic:baseline-keyboard-arrow-right />
</div> </div>
</template> </template>
<div <div
ref="content" ref="content"
class="tags-content" class="content"
:class="{ overflow: isOverflow && showArrow }" :class="{ overflow: isOverflow && showArrow }"
:style="{ :style="{
height: height + 'px',
transform: `translateX(${translateX}px)`, transform: `translateX(${translateX}px)`,
}" }"
> >
@@ -25,37 +24,26 @@
<script setup> <script setup>
import { debounce } from '@/utils' import { debounce } from '@/utils'
import { isNullOrUndef } from '@/utils/is'
defineProps({ defineProps({
height: {
type: Number,
default: 50,
},
showArrow: { showArrow: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}) })
onMounted(() => {
refreshIsOverflow()
})
const translateX = ref(0) const translateX = ref(0)
const content = ref(null) const content = ref(null)
const wrapper = ref(null) const wrapper = ref(null)
const isOverflow = ref(false) const isOverflow = ref(false)
function refreshIsOverflow(isIncrease) { const refreshIsOverflow = debounce(() => {
isOverflow.value = content.value.offsetWidth > wrapper.value.offsetWidth const wrapperWidth = wrapper.value.offsetWidth
if (isNullOrUndef(isIncrease)) return const contentWidth = content.value.offsetWidth
if (isOverflow.value) { isOverflow.value = contentWidth > wrapperWidth
handleMouseWheel({ wheelDelta: isIncrease ? -100 : 100 }) resetTranslateX(wrapperWidth, contentWidth)
} else if (!isIncrease && translateX.value < 0) { }, 200)
handleMouseWheel({ wheelDelta: 100 })
}
}
function handleMouseWheel(e) { function handleMouseWheel(e) {
const { wheelDelta } = e const { wheelDelta } = e
const wrapperWidth = wrapper.value.offsetWidth const wrapperWidth = wrapper.value.offsetWidth
@@ -66,15 +54,15 @@ function handleMouseWheel(e) {
* @wrapperWidth 容器的宽度 * @wrapperWidth 容器的宽度
* @contentWidth 内容的宽度 * @contentWidth 内容的宽度
*/ */
if (wheelDelta < 0 && -translateX.value > contentWidth - wrapperWidth + 10) { if (wheelDelta < 0) {
return if (wrapperWidth > contentWidth && translateX.value < -10) return
if (wrapperWidth <= contentWidth && contentWidth + translateX.value - wrapperWidth < -10) return
} }
if (wheelDelta > 0 && translateX.value > 10) { if (wheelDelta > 0 && translateX.value > 10) {
return return
} }
translateX.value += wheelDelta translateX.value += wheelDelta
resetTranslateX(wrapperWidth, contentWidth) resetTranslateX(wrapperWidth, contentWidth)
} }
@@ -88,20 +76,29 @@ const resetTranslateX = debounce(function (wrapperWidth, contentWidth) {
} }
}, 200) }, 200)
defineExpose({ const observer = new MutationObserver(refreshIsOverflow)
refreshIsOverflow, onMounted(() => {
refreshIsOverflow()
window.addEventListener('resize', refreshIsOverflow)
// 监听内容宽度刷新是否超出
observer.observe(content.value, { childList: true })
})
onBeforeUnmount(() => {
window.removeEventListener('resize', refreshIsOverflow)
observer.disconnect()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tags-wrapper { .wrapper {
display: flex; display: flex;
background-color: #fff; background-color: #fff;
position: sticky;
top: 0;
z-index: 9; z-index: 9;
overflow: hidden; overflow: hidden;
.tags-content { position: relative;
.content {
padding: 0 10px; padding: 0 10px;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -0,0 +1,25 @@
<script setup>
import { renderCustomIcon } from '@/utils/icon'
const props = defineProps({
/** 图标名称(图片的文件名) */
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
})
const iconCom = computed(() => renderCustomIcon(props.icon, props))
</script>
<template>
<component :is="iconCom" />
</template>

View File

@@ -0,0 +1,24 @@
<script setup name="SvgIcon">
const props = defineProps({
icon: {
type: String,
required: true,
},
prefix: {
type: String,
default: 'icon-custom',
},
color: {
type: String,
default: 'currentColor',
},
})
const symbolId = computed(() => `#${props.prefix}-${props.icon}`)
</script>
<template>
<svg aria-hidden="true" width="1em" height="1em">
<use :xlink:href="symbolId" :fill="color" />
</svg>
</template>

View File

@@ -1,12 +1,19 @@
<template> <template>
<n-breadcrumb> <n-breadcrumb>
<n-breadcrumb-item v-for="item in route.matched" :key="item.path" @click="handleBreadClick(item.path)"> <n-breadcrumb-item
v-for="item in route.matched.filter((item) => !!item.meta?.title)"
:key="item.path"
@click="handleBreadClick(item.path)"
>
<component :is="getIcon(item.meta)" />
{{ item.meta.title }} {{ item.meta.title }}
</n-breadcrumb-item> </n-breadcrumb-item>
</n-breadcrumb> </n-breadcrumb>
</template> </template>
<script setup> <script setup>
import { renderCustomIcon, renderIcon } from '@/utils/icon'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -14,4 +21,10 @@ function handleBreadClick(path) {
if (path === route.path) return if (path === route.path) return
router.push(path) router.push(path)
} }
function getIcon(meta) {
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<n-icon mr20 size="18" style="cursor: pointer" @click="toggle"> <n-icon mr20 size="18" style="cursor: pointer" @click="toggle">
<icon-ant-design:fullscreen-outlined v-if="isFullscreen" /> <icon-ant-design:fullscreen-exit-outlined v-if="isFullscreen" />
<icon-ant-design:fullscreen-outlined v-else /> <icon-ant-design:fullscreen-outlined v-else />
</n-icon> </n-icon>
</template> </template>

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<n-icon size="20" style="cursor: pointer" @click="appStore.switchCollapsed"> <n-icon size="20" cursor-pointer @click="appStore.switchCollapsed">
<icon-mdi:format-indent-increase v-if="appStore.collapsed" /> <icon-mdi:format-indent-increase v-if="appStore.collapsed" />
<icon-mdi:format-indent-decrease v-else /> <icon-mdi:format-indent-decrease v-else />
</n-icon> </n-icon>

View File

@@ -1,7 +1,7 @@
<template> <template>
<n-dropdown :options="options" @select="handleSelect"> <n-dropdown :options="options" @select="handleSelect">
<div class="avatar"> <div flex items-center cursor-pointer>
<img :src="userStore.avatar" /> <img :src="userStore.avatar" mr10 w-35 h-35 rounded-full />
<span>{{ userStore.name }}</span> <span>{{ userStore.name }}</span>
</div> </div>
</n-dropdown> </n-dropdown>
@@ -35,18 +35,3 @@ function handleSelect(key) {
} }
} }
</script> </script>
<style lang="scss" scoped>
.avatar {
display: flex;
align-items: center;
cursor: pointer;
img {
width: 100%;
width: 35px;
height: 35px;
border-radius: 50%;
margin-right: 10px;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template> <template>
<header class="header"> <header px-15 h-full flex items-center>
<div class="h-left"> <div flex items-center>
<MenuCollapse /> <MenuCollapse />
<BreadCrumb ml-15 /> <BreadCrumb ml-15 />
</div> </div>
<div class="h-right"> <div ml-auto flex items-center>
<GithubSite /> <GithubSite />
<FullScreen /> <FullScreen />
<UserAvatar /> <UserAvatar />
@@ -19,21 +19,3 @@ import FullScreen from './components/FullScreen.vue'
import UserAvatar from './components/UserAvatar.vue' import UserAvatar from './components/UserAvatar.vue'
import GithubSite from './components/GithubSite.vue' import GithubSite from './components/GithubSite.vue'
</script> </script>
<style lang="scss" scoped>
.header {
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>

View File

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

View File

@@ -16,7 +16,7 @@ import { usePermissionStore } from '@/store/modules/permission'
import { isExternal } from '@/utils/is' import { isExternal } from '@/utils/is'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { renderIcon } from '@/utils/icon' import { renderCustomIcon, renderIcon } from '@/utils/icon'
const router = useRouter() const router = useRouter()
const permissionStore = usePermissionStore() const permissionStore = usePermissionStore()
@@ -24,7 +24,7 @@ const appStore = useAppStore()
const { currentRoute } = router const { currentRoute } = router
const menuOptions = computed(() => { const menuOptions = computed(() => {
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.index - b.index) return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
}) })
function resolvePath(basePath, path) { function resolvePath(basePath, path) {
@@ -43,8 +43,8 @@ function getMenuItem(route, basePath = '') {
label: (route.meta && route.meta.title) || route.name, label: (route.meta && route.meta.title) || route.name,
key: route.name, key: route.name,
path: resolvePath(basePath, route.path), path: resolvePath(basePath, route.path),
icon: route.meta?.icon ? renderIcon(route.meta?.icon, { size: 16 }) : renderIcon('mdi:circle-outline', { size: 8 }), icon: getIcon(route.meta),
index: route.meta?.index || 0, order: route.meta?.order || 0,
} }
const visibleChildren = route.children ? route.children.filter((item) => item.name && !item.isHidden) : [] const visibleChildren = route.children ? route.children.filter((item) => item.name && !item.isHidden) : []
@@ -58,27 +58,31 @@ function getMenuItem(route, basePath = '') {
label: singleRoute.meta?.title || singleRoute.name, label: singleRoute.meta?.title || singleRoute.name,
key: singleRoute.name, key: singleRoute.name,
path: resolvePath(menuItem.path, singleRoute.path), path: resolvePath(menuItem.path, singleRoute.path),
icon: singleRoute.meta?.icon icon: getIcon(singleRoute.meta),
? renderIcon(singleRoute.meta?.icon, { size: 16 }) order: menuItem.order,
: renderIcon('mdi:circle-outline', { size: 8 }),
index: menuItem.index,
} }
const visibleItems = singleRoute.children ? singleRoute.children.filter((item) => item.name && !item.isHidden) : [] const visibleItems = singleRoute.children ? singleRoute.children.filter((item) => item.name && !item.isHidden) : []
if (visibleItems.length === 1) { if (visibleItems.length === 1) {
menuItem = getMenuItem(visibleItems[0], menuItem.path) menuItem = getMenuItem(visibleItems[0], menuItem.path)
} else if (visibleItems.length > 1) { } else if (visibleItems.length > 1) {
menuItem.children = visibleItems.map((item) => getMenuItem(item, menuItem.path)).sort((a, b) => a.index - b.index) menuItem.children = visibleItems.map((item) => getMenuItem(item, menuItem.path)).sort((a, b) => a.order - b.order)
} }
} else { } else {
menuItem.children = visibleChildren menuItem.children = visibleChildren
.map((item) => getMenuItem(item, menuItem.path)) .map((item) => getMenuItem(item, menuItem.path))
.sort((a, b) => a.index - b.index) .sort((a, b) => a.order - b.order)
} }
return menuItem return menuItem
} }
function getIcon(meta) {
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
function handleMenuSelect(key, item) { function handleMenuSelect(key, item) {
if (isExternal(item.path)) { if (isExternal(item.path)) {
window.open(item.path) window.open(item.path)

View File

@@ -1,6 +1,6 @@
<template> <template>
<n-dropdown <n-dropdown
:show="dropdownShow" :show="show"
:options="options" :options="options"
:x="x" :x="x"
:y="y" :y="y"
@@ -72,15 +72,6 @@ const options = computed(() => [
}, },
]) ])
const dropdownShow = computed({
get() {
return props.show
},
set(show) {
emit('update:show', show)
},
})
const actionMap = new Map([ const actionMap = new Map([
[ [
'reload', 'reload',
@@ -115,7 +106,7 @@ const actionMap = new Map([
]) ])
function handleHideDropdown() { function handleHideDropdown() {
dropdownShow.value = false emit('update:show', false)
} }
function handleSelect(key) { function handleSelect(key) {

View File

@@ -1,8 +1,9 @@
<template> <template>
<ScrollX ref="scrollX" :height="useTheme.tags.height"> <ScrollX>
<n-tag <n-tag
v-for="tag in tagsStore.tags" v-for="tag in tagsStore.tags"
:key="tag.path" :key="tag.path"
class="px-15 mx-5 rounded-4 cursor-pointer hover:color-primary"
:type="tagsStore.activeTag === tag.path ? 'primary' : 'default'" :type="tagsStore.activeTag === tag.path ? 'primary' : 'default'"
:closable="tagsStore.tags.length > 1" :closable="tagsStore.tags.length > 1"
@click="handleTagClick(tag.path)" @click="handleTagClick(tag.path)"
@@ -11,26 +12,24 @@
> >
{{ tag.title }} {{ tag.title }}
</n-tag> </n-tag>
</ScrollX>
<ContextMenu <ContextMenu
v-if="contextMenuOption.show"
v-model:show="contextMenuOption.show" v-model:show="contextMenuOption.show"
:current-path="contextMenuOption.currentPath" :current-path="contextMenuOption.currentPath"
:x="contextMenuOption.x" :x="contextMenuOption.x"
:y="contextMenuOption.y" :y="contextMenuOption.y"
/> />
</ScrollX>
</template> </template>
<script setup name="Tags"> <script setup name="Tags">
import ContextMenu from './ContextMenu.vue' import ContextMenu from './ContextMenu.vue'
import { useTagsStore } from '@/store/modules/tags' import { useTagsStore } from '@/store/modules/tags'
import { useThemeStore } from '@/store/modules/theme'
import ScrollX from '@/components/common/ScrollX.vue' import ScrollX from '@/components/common/ScrollX.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const tagsStore = useTagsStore() const tagsStore = useTagsStore()
const useTheme = useThemeStore()
const contextMenuOption = reactive({ const contextMenuOption = reactive({
show: false, show: false,
@@ -49,15 +48,6 @@ watch(
{ immediate: true } { immediate: true }
) )
const scrollX = ref(null)
watch(
() => tagsStore.tags,
async (newVal, oldVal) => {
await nextTick()
scrollX.value?.refreshIsOverflow(newVal.length > oldVal.length)
}
)
const handleTagClick = (path) => { const handleTagClick = (path) => {
tagsStore.setActiveTag(path) tagsStore.setActiveTag(path)
router.push(path) router.push(path)
@@ -83,25 +73,14 @@ async function handleContextMenu(e, tagItem) {
} }
</script> </script>
<style lang="scss"> <style>
.n-tag {
padding: 0 15px;
margin: 0 5px;
cursor: pointer;
.n-tag__close { .n-tag__close {
margin-left: 5px;
box-sizing: content-box; box-sizing: content-box;
border-radius: 50%;
font-size: 12px; font-size: 12px;
padding: 2px; padding: 2px;
border-radius: 50%; transform: scale(0.9);
transition: all 0.7s; transform: translateX(5px);
&:hover { transition: all 0.3s;
color: #fff;
background-color: var(--primaryColor);
}
}
&:hover {
color: var(--primaryColor);
}
} }
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<n-layout has-sider style="height: 100%"> <n-layout has-sider h-full>
<n-layout-sider <n-layout-sider
bordered bordered
collapse-mode="width" collapse-mode="width"
@@ -11,18 +11,15 @@
<SideBar /> <SideBar />
</n-layout-sider> </n-layout-sider>
<n-layout> <n-layout>
<n-layout-header :style="{ height: useTheme.header.height + 'px' }"> <n-layout-header bg-white border-b bc-eee :style="`height: ${header.height ?? 60}px`">
<AppHeader /> <AppHeader />
</n-layout-header> </n-layout-header>
<n-layout style="background-color: #f5f6fb" :style="`height: calc(100% - ${useTheme.header.height}px)`"> <n-layout bg="#f5f6fb" :style="`height: calc(100% - ${header.height ?? 60}px)`">
<AppTags v-if="useTheme.tags.visible" /> <AppTags v-if="tags.visible" :style="`height: ${tags.height ?? 50}px`" />
<AppMain <AppMain
class="cur-scroll border-t bc-eee" class="cus-scroll border-t bc-eee overflow-auto"
:style="{ :style="{ height: `calc(100% - ${tags.visible ? tags.height ?? 50 : 0}px)` }"
height: `calc(100% - ${useTheme.tags.visible ? useTheme.tags.height : 0}px)`,
overflow: 'auto',
}"
/> />
</n-layout> </n-layout>
</n-layout> </n-layout>
@@ -34,17 +31,8 @@ import AppHeader from './components/header/index.vue'
import SideBar from './components/sidebar/index.vue' import SideBar from './components/sidebar/index.vue'
import AppMain from './components/AppMain.vue' import AppMain from './components/AppMain.vue'
import AppTags from './components/tags/index.vue' import AppTags from './components/tags/index.vue'
import { useThemeStore } from '@/store/modules/theme'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { header, tags } from '~/settings'
const useTheme = useThemeStore()
const appStore = useAppStore() const appStore = useAppStore()
</script> </script>
<style lang="scss" scoped>
.n-layout-header {
height: 60px;
background-color: #fff;
border-bottom: 1px solid #eee;
}
</style>

View File

@@ -1,7 +1,7 @@
import '@/styles/reset.css' import '@/styles/reset.css'
import '@/styles/variables.css'
import '@/styles/index.scss' import '@/styles/index.scss'
import 'uno.css' import 'uno.css'
import 'virtual:svg-icons-register'
import { createApp } from 'vue' import { createApp } from 'vue'
import { setupRouter } from '@/router' import { setupRouter } from '@/router'

View File

@@ -1,4 +1,4 @@
const baseTitle = import.meta.env.VITE_APP_TITLE const baseTitle = import.meta.env.VITE_TITLE
export function createPageTitleGuard(router) { export function createPageTitleGuard(router) {
router.afterEach((to) => { router.afterEach((to) => {

View File

@@ -3,42 +3,40 @@ import { usePermissionStore } from '@/store/modules/permission'
import { NOT_FOUND_ROUTE } from '@/router/routes' import { NOT_FOUND_ROUTE } from '@/router/routes'
import { getToken, refreshAccessToken, removeToken } from '@/utils/token' import { getToken, refreshAccessToken, removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth' import { toLogin } from '@/utils/auth'
import { isNullOrWhitespace } from '@/utils/is'
const WHITE_LIST = ['/login', '/redirect'] const WHITE_LIST = ['/login']
export function createPermissionGuard(router) { export function createPermissionGuard(router) {
const userStore = useUserStore() const userStore = useUserStore()
const permissionStore = usePermissionStore() const permissionStore = usePermissionStore()
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to) => {
const token = getToken() const token = getToken()
if (token) {
if (to.path === '/login') { /** 没有token的情况 */
next({ path: '/' }) if (isNullOrWhitespace(token)) {
} else { if (WHITE_LIST.includes(to.path)) return true
if (userStore.userId) { return { path: 'login', query: { ...to.query, redirect: to.path } }
}
/** 有token的情况 */
if (to.path === '/login') return { path: '/', replace: true }
// 已经拿到用户信息 // 已经拿到用户信息
if (userStore.userId) {
refreshAccessToken() refreshAccessToken()
next() return true
} else { }
await userStore.getUserInfo().catch((error) => { await userStore.getUserInfo().catch((error) => {
removeToken() removeToken()
toLogin() toLogin()
$message.error(error.message || '获取用户信息失败!') $message.error(error.message || '获取用户信息失败!')
return
}) })
const accessRoutes = permissionStore.generateRoutes(userStore.role) const accessRoutes = permissionStore.generateRoutes(userStore.role)
accessRoutes.forEach((route) => { accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route) !router.hasRoute(route.name) && router.addRoute(route)
}) })
router.addRoute(NOT_FOUND_ROUTE) router.addRoute(NOT_FOUND_ROUTE)
next({ ...to, replace: true }) return { ...to, replace: true }
}
}
} else {
if (WHITE_LIST.includes(to.path)) {
next()
} else {
next({ path: '/login', query: { ...to.query, redirect: to.path } })
}
}
}) })
} }

View File

@@ -2,7 +2,7 @@ import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router
import { setupRouterGuard } from './guard' import { setupRouterGuard } from './guard'
import { basicRoutes } from './routes' import { basicRoutes } from './routes'
const isHash = !!import.meta.env.VITE_APP_USE_HASH const isHash = import.meta.env.VITE_USE_HASH === 'true'
export const router = createRouter({ export const router = createRouter({
history: isHash ? createWebHashHistory('/') : createWebHistory('/'), history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
routes: [], routes: [],

View File

@@ -1,5 +1,4 @@
import Layout from '@/layout/index.vue' const Layout = () => import('@/layout/index.vue')
import Home from '@/views/dashboard/index.vue'
export const basicRoutes = [ export const basicRoutes = [
{ {
@@ -8,21 +7,9 @@ export const basicRoutes = [
component: () => import('@/views/error-page/404.vue'), component: () => import('@/views/error-page/404.vue'),
isHidden: true, isHidden: true,
}, },
{ {
name: 'REDIRECT', name: 'Login',
path: '/redirect',
component: Layout,
isHidden: true,
children: [
{
name: 'REDIRECT_NAME',
path: '',
component: () => import('@/views/redirect/index.vue'),
},
],
},
{
name: 'LOGIN',
path: '/login', path: '/login',
component: () => import('@/views/login/index.vue'), component: () => import('@/views/login/index.vue'),
isHidden: true, isHidden: true,
@@ -31,97 +18,6 @@ export const basicRoutes = [
}, },
}, },
{
name: 'Dashboard',
path: '/',
component: Layout,
redirect: '/home',
meta: {
title: 'Dashboard',
icon: 'mdi:chart-bar',
},
children: [
{
name: 'Home',
path: 'home',
component: Home,
meta: {
title: '首页',
icon: 'mdi:home',
},
},
],
},
{
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',
path: 'unocss',
component: () => import('@/views/test-page/unocss/index.vue'),
meta: {
title: '测试unocss',
},
},
{
name: 'Message',
path: 'message',
component: () => import('@/views/test-page/message/index.vue'),
meta: {
title: '测试Message',
},
},
{
name: 'Dialog',
path: 'dialog',
component: () => import('@/views/test-page/dialog/index.vue'),
meta: {
title: '测试Dialog',
},
},
{
name: 'TestKeepAlive',
path: 'keep-alive',
component: () => import('@/views/test-page/keep-alive/index.vue'),
meta: {
title: '测试Keep-Alive',
keepAlive: true,
},
},
],
},
{ {
name: 'ExternalLink', name: 'ExternalLink',
path: '/external-link', path: '/external-link',
@@ -129,6 +25,7 @@ export const basicRoutes = [
meta: { meta: {
title: '外部链接', title: '外部链接',
icon: 'mdi:link-variant', icon: 'mdi:link-variant',
order: 2,
}, },
children: [ children: [
{ {
@@ -166,10 +63,10 @@ export const NOT_FOUND_ROUTE = {
isHidden: true, isHidden: true,
} }
const modules = import.meta.globEager('./modules/*.js') const modules = import.meta.glob('@/views/**/route.js', { eager: true })
const asyncRoutes = [] const asyncRoutes = []
Object.keys(modules).forEach((key) => { Object.keys(modules).forEach((key) => {
asyncRoutes.push(...modules[key].default) asyncRoutes.push(modules[key].default)
}) })
export { asyncRoutes } export { asyncRoutes }

View File

@@ -1,47 +0,0 @@
import Layout from '@/layout/index.vue'
export default [
{
name: 'Example',
path: '/example',
component: Layout,
redirect: '/example/table',
meta: {
title: '组件示例',
role: ['admin'],
},
children: [
{
name: 'Table',
path: 'table',
component: () => import('@/views/examples/table/index.vue'),
redirect: '/example/table/post',
meta: {
title: '表格',
role: ['admin'],
icon: 'mdi:table',
},
children: [
{
name: 'PostList',
path: 'post',
component: () => import('@/views/examples/table/post/index.vue'),
meta: {
title: '文章列表',
role: ['admin'],
},
},
{
name: 'PostCreate',
path: 'post-create',
component: () => import('@/views/examples/table/post/PostCreate.vue'),
meta: {
title: '创建文章',
role: ['admin'],
},
},
],
},
],
},
]

View File

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

View File

@@ -2,10 +2,15 @@ import { defineStore } from 'pinia'
import { asyncRoutes, basicRoutes } from '@/router/routes' import { asyncRoutes, basicRoutes } from '@/router/routes'
function hasPermission(route, role) { function hasPermission(route, role) {
// * 不需要权限直接返回true
if (!route.meta?.requireAuth) return true
const routeRole = route.meta?.role ? route.meta.role : [] const routeRole = route.meta?.role ? route.meta.role : []
if (!role.length || !routeRole.length) {
return false // * 登录用户没有角色或者路由没有设置角色判定为没有权限
} if (!role.length || !routeRole.length) return false
// * 路由指定的角色包含任一登录用户角色则判定有权限
return role.some((item) => routeRole.includes(item)) return role.some((item) => routeRole.includes(item))
} }

View File

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

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { tagsSS, activeTag, tags, WITHOUT_TAG_PATHS } from './helpers' import { activeTag, tags, WITHOUT_TAG_PATHS } from './helpers'
import { router } from '@/router' import { router } from '@/router'
import { sStorage } from '@/utils/cache'
export const useTagsStore = defineStore('tag', { export const useTagsStore = defineStore('tag', {
state() { state() {
@@ -12,11 +13,11 @@ export const useTagsStore = defineStore('tag', {
actions: { actions: {
setActiveTag(path) { setActiveTag(path) {
this.activeTag = path this.activeTag = path
tagsSS.set('activeTag', path) sStorage.set('activeTag', path)
}, },
setTags(tags) { setTags(tags) {
this.tags = tags this.tags = tags
tagsSS.set('tags', tags) sStorage.set('tags', tags)
}, },
addTag(tag = {}) { addTag(tag = {}) {
this.setActiveTag(tag.path) this.setActiveTag(tag.path)

View File

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

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { getUser } from '@/api/user'
import { removeToken } from '@/utils/token' import { removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth' import { toLogin } from '@/utils/auth'
import api from '@/api'
export const useUserStore = defineStore('user', { export const useUserStore = defineStore('user', {
state() { state() {
@@ -26,7 +26,7 @@ export const useUserStore = defineStore('user', {
actions: { actions: {
async getUserInfo() { async getUserInfo() {
try { try {
const res = await getUser() const res = await api.getUser()
if (res.code === 0) { if (res.code === 0) {
const { id, name, avatar, role } = res.data const { id, name, avatar, role } = res.data
this.userInfo = { id, name, avatar, role } this.userInfo = { id, name, avatar, role }

View File

@@ -11,6 +11,11 @@ body {
font-family: 'Encode Sans Condensed', sans-serif; font-family: 'Encode Sans Condensed', sans-serif;
} }
#app {
width: 100%;
height: 100%;
}
/* router view transition fade-slide */ /* router view transition fade-slide */
.fade-slide-leave-active, .fade-slide-leave-active,
.fade-slide-enter-active { .fade-slide-enter-active {
@@ -28,16 +33,15 @@ body {
} }
/* 自定义滚动条样式 */ /* 自定义滚动条样式 */
.cur-scroll { .cus-scroll {
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 8px;
height: 6px; height: 8px;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: transparent; background-color: transparent;
border-radius: 3px; border-radius: 3px;
} }
&::-webkit-scrollbar-corner { &::-webkit-scrollbar-corner {
background: #f6f6f6; background: #f6f6f6;
} }
@@ -50,5 +54,3 @@ body {
} }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { isNullOrUndef } from '@/utils/is' import { isNullOrUndef } from '@/utils/is'
class WebStorage { class Storage {
constructor(option) { constructor(option) {
this.storage = option.storage this.storage = option.storage
this.prefixKey = option.prefixKey this.prefixKey = option.prefixKey
@@ -50,6 +50,6 @@ class WebStorage {
} }
} }
export function createWebStorage({ prefixKey = '', storage = sessionStorage }) { export function createStorage({ prefixKey = '', storage = sessionStorage }) {
return new WebStorage({ prefixKey, storage }) return new Storage({ prefixKey, storage })
} }

View File

@@ -3,7 +3,6 @@ import { repReject, repResolve, reqReject, reqResolve } from './interceptors'
export function createAxios(options = {}) { export function createAxios(options = {}) {
const defaultOptions = { const defaultOptions = {
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 12000, timeout: 12000,
} }
const service = axios.create({ const service = axios.create({
@@ -15,8 +14,6 @@ export function createAxios(options = {}) {
return service return service
} }
export const defAxios = createAxios() export default createAxios({
baseURL: import.meta.env.VITE_BASE_API,
export const testAxios = createAxios({
baseURL: import.meta.env.VITE_APP_BASE_API_TEST,
}) })

View File

@@ -16,17 +16,14 @@ export function reqResolve(config) {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
/** // * 未登录或者token过期的情况下,跳转登录页重新登录
* * 未登录或者token过期的情况下
* * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面
*/
toLogin() toLogin()
return Promise.reject({ code: '-1', message: '未登录' }) return Promise.reject({ code: '-1', message: '未登录' })
} }
/** /**
* * jwt token * * 加上 token
* ! 认证方案: Bearer * ! 认证方案: JWT Bearer
*/ */
config.headers.Authorization = config.headers.Authorization || 'Bearer ' + token config.headers.Authorization = config.headers.Authorization || 'Bearer ' + token
@@ -52,6 +49,9 @@ export function repReject(error) {
* TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理 * TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
*/ */
switch (code) { switch (code) {
case 400:
message = message || '请求参数错误'
break
case 401: case 401:
message = message || '登录已过期' message = message || '登录已过期'
break break

View File

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

View File

@@ -1,25 +1,23 @@
import { createLocalStorage } from './cache' import { lStorage } from './cache'
import { refreshToken } from '@/api/auth' import api from '@/api'
const TOKEN_CODE = 'access_token' const TOKEN_CODE = 'access_token'
const DURATION = 6 * 60 * 60 const DURATION = 6 * 60 * 60
export const lsToken = createLocalStorage()
export function getToken() { export function getToken() {
return lsToken.get(TOKEN_CODE) return lStorage.get(TOKEN_CODE)
} }
export function setToken(token) { export function setToken(token) {
lsToken.set(TOKEN_CODE, token, DURATION) lStorage.set(TOKEN_CODE, token, DURATION)
} }
export function removeToken() { export function removeToken() {
lsToken.remove(TOKEN_CODE) lStorage.remove(TOKEN_CODE)
} }
export async function refreshAccessToken() { export async function refreshAccessToken() {
const tokenItem = lsToken.getItem(TOKEN_CODE) const tokenItem = lStorage.getItem(TOKEN_CODE)
if (!tokenItem) { if (!tokenItem) {
return return
} }
@@ -27,7 +25,7 @@ export async function refreshAccessToken() {
// token生成或者刷新后30分钟内不执行刷新 // token生成或者刷新后30分钟内不执行刷新
if (new Date().getTime() - time <= 1000 * 60 * 30) return if (new Date().getTime() - time <= 1000 * 60 * 30) return
try { try {
const res = await refreshToken() const res = await api.refreshToken()
if (res.code === 0) { if (res.code === 0) {
setToken(res.data.token) setToken(res.data.token)
} }

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="page-404"> <div h-full flex>
<n-result status="404" description="抱歉,您访问的页面不存在。"> <n-result m-auto status="404" description="抱歉,您访问的页面不存在。">
<template #icon> <template #icon>
<img src="@/assets/images/404.png" width="500" /> <img src="@/assets/images/404.webp" width="500" />
</template> </template>
<template #footer> <template #footer>
<n-button strong secondary type="primary" @click="replace('/')">返回首页</n-button> <n-button @click="replace('/')">返回首页</n-button>
</template> </template>
</n-result> </n-result>
</div> </div>
@@ -14,12 +14,3 @@
<script setup> <script setup>
const { replace } = useRouter() const { replace } = useRouter()
</script> </script>
<style lang="scss" scoped>
.page-404 {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,24 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'ErrorPage',
path: '/error-page',
component: Layout,
redirect: '/error-page/404',
meta: {
title: '错误页',
icon: 'mdi:alert-circle-outline',
order: 99,
},
children: [
{
name: 'ERROR-404',
path: '404',
component: () => import('./404.vue'),
meta: {
title: '404',
icon: 'tabler:error-404',
},
},
],
}

View File

@@ -1,10 +1,15 @@
<template> <template>
<div p-20> <div p-24>
<div class="header"> <div h-60 pl-20 pr-20 flex items-center bg-white>
<input v-model="post.title" type="text" placeholder="输入文章标题..." class="title" /> <input
v-model="post.title"
class="flex-1 pt-15 pb-15 mr-20 text-20 font-bold color-primary"
type="text"
placeholder="输入文章标题..."
/>
<n-button type="primary" style="width: 80px" :loading="btnLoading" @click="handleSavePost">保存</n-button> <n-button type="primary" style="width: 80px" :loading="btnLoading" @click="handleSavePost">保存</n-button>
</div> </div>
<MdEditor v-model="post.content" style="height: calc(100vh - 210px)" /> <MdEditor v-model="post.content" style="height: calc(100vh - 220px)" />
</div> </div>
</template> </template>
@@ -37,21 +42,3 @@ function handleSavePost(e) {
} }
} }
</style> </style>
<style lang="scss" scoped>
.header {
background-color: #fff;
height: 60px;
padding: 0 20px;
display: flex;
align-items: center;
.title {
flex: 1;
padding: 15px 0;
margin-right: 20px;
font-size: 20px;
font-weight: bold;
color: var(--primaryColor);
}
}
</style>

View File

@@ -0,0 +1,13 @@
import request from '@/utils/http'
export default {
getPosts: (params = {}) => request.get('posts', { params }),
getPostById: (id) => request.get(`/post/${id}`),
savePost: (id, data = {}) => {
if (id) {
return request.put(`/post/${id}`, data)
}
return request.post('/post', data)
},
deletePost: (id) => request.delete(`/post/${id}`),
}

View File

@@ -1,6 +1,6 @@
<template> <template>
<div p24> <div p-24>
<div class="action-btns"> <div flex>
<n-button size="small" type="primary" @click="handleCreate">新建文章</n-button> <n-button size="small" type="primary" @click="handleCreate">新建文章</n-button>
</div> </div>
<n-data-table <n-data-table
@@ -37,9 +37,3 @@ function handleCheck(rowKeys) {
if (rowKeys.length) $message.info(`选中${rowKeys.join(' ')}`) if (rowKeys.length) $message.info(`选中${rowKeys.join(' ')}`)
} }
</script> </script>
<style lang="scss" scoped>
.action-btns {
display: flex;
}
</style>

View File

@@ -1,7 +1,8 @@
import { h } from 'vue' import { h } from 'vue'
import { NButton, NSwitch } from 'naive-ui' import { NButton, NSwitch } from 'naive-ui'
import { getPosts } from '@/api/post'
import { formatDateTime } from '@/utils' import { formatDateTime } from '@/utils'
import api from './api'
import { renderIcon } from '@/utils/icon'
export const usePostTable = () => { export const usePostTable = () => {
// refs // refs
@@ -34,8 +35,9 @@ export const usePostTable = () => {
if (row && row.id) { if (row && row.id) {
row.recommending = true row.recommending = true
setTimeout(() => { setTimeout(() => {
$message.success(row.isRecommend ? '已取消推荐' : '已推荐') row.isRecommend = !row.isRecommend
row.recommending = false row.recommending = false
$message.success(row.isRecommend ? '已推荐' : '已取消推荐')
}, 800) }, 800)
} }
} }
@@ -44,8 +46,9 @@ export const usePostTable = () => {
if (row && row.id) { if (row && row.id) {
row.publishing = true row.publishing = true
setTimeout(() => { setTimeout(() => {
$message.success(row.isPublish ? '已取消推荐' : '已推荐') row.isPublish = !row.isPublish
row.publishing = false row.publishing = false
$message.success(row.isPublish ? '已发布' : '已取消发布')
}, 800) }, 800)
} }
} }
@@ -53,12 +56,13 @@ export const usePostTable = () => {
function initColumns() { function initColumns() {
columns.value = [ columns.value = [
{ type: 'selection' }, { type: 'selection' },
{ title: '标题', key: 'title', width: 150 }, { title: '标题', key: 'title', width: 150, ellipsis: { tooltip: true } },
{ title: '分类', key: 'category', width: 80 }, { title: '分类', key: 'category', width: 80, ellipsis: { tooltip: true } },
{ {
title: '描述', title: '描述',
key: 'description', key: 'description',
width: 200, width: 200,
ellipsis: { tooltip: true },
}, },
{ title: '创建人', key: 'author', width: 80 }, { title: '创建人', key: 'author', width: 80 },
{ {
@@ -86,7 +90,8 @@ export const usePostTable = () => {
render(row) { render(row) {
return h(NSwitch, { return h(NSwitch, {
size: 'small', size: 'small',
defaultValue: row['isRecommend'], value: row['isRecommend'],
rubberBand: false,
loading: !!row.recommending, loading: !!row.recommending,
onUpdateValue: () => handleRecommend(row), onUpdateValue: () => handleRecommend(row),
}) })
@@ -101,7 +106,8 @@ export const usePostTable = () => {
render(row) { render(row) {
return h(NSwitch, { return h(NSwitch, {
size: 'small', size: 'small',
defaultValue: row['isPublish'], rubberBand: false,
value: row['isPublish'],
loading: !!row.publishing, loading: !!row.publishing,
onUpdateValue: () => handlePublish(row), onUpdateValue: () => handlePublish(row),
}) })
@@ -123,7 +129,7 @@ export const usePostTable = () => {
style: 'margin-left: 15px;', style: 'margin-left: 15px;',
onClick: () => handleDelete(row), onClick: () => handleDelete(row),
}, },
{ default: () => '删除' } { default: () => '删除', icon: renderIcon('material-symbols:delete-outline', { size: 14 }) }
), ),
] ]
}, },
@@ -133,7 +139,7 @@ export const usePostTable = () => {
async function getTableData() { async function getTableData() {
try { try {
const res = await getPosts() const res = await api.getPosts()
if (res.code === 0) { if (res.code === 0) {
return res.data return res.data
} }

View File

@@ -0,0 +1,53 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'Example',
path: '/example',
component: Layout,
redirect: '/example/table',
meta: {
title: '组件示例',
icon: 'mdi:menu',
role: ['admin'],
requireAuth: true,
order: 3,
},
children: [
{
name: 'Table',
path: 'table',
component: () => import('./index.vue'),
redirect: '/example/table/post',
meta: {
title: '表格',
icon: 'mdi:table',
role: ['admin'],
requireAuth: true,
},
children: [
{
name: 'PostList',
path: 'post',
component: () => import('./post/index.vue'),
meta: {
title: '文章列表',
icon: 'material-symbols:auto-awesome-outline-rounded',
role: ['admin'],
requireAuth: true,
},
},
{
name: 'PostCreate',
path: 'post-create',
component: () => import('./post/PostCreate.vue'),
meta: {
title: '创建文章',
icon: 'material-symbols:auto-awesome-outline-rounded',
role: ['admin'],
requireAuth: true,
},
},
],
},
],
}

5
src/views/login/api.js Normal file
View File

@@ -0,0 +1,5 @@
import request from '@/utils/http'
export default {
login: (data) => request.post('/auth/login', data),
}

View File

@@ -1,66 +1,77 @@
<template> <template>
<div class="login-bg" f-c-c h-full> <div class="cus-scroll h-full py-15 flex-col overflow-auto bg-cover" :style="{ backgroundImage: `url(${bgImg})` }">
<div class="login-wrapper" flex w-full max-w-1020> <div m-auto p-15 f-c-c min-w-345 rounded-10 card-shadow bg-white dark:bg-dark bg-opacity-60>
<div p-40 border-r border-gray-200> <div w-380 hidden md:block px-20 py-35>
<img src="@/assets/images/login_banner.png" height="380" alt="login_banner" /> <img src="@/assets/images/login_banner.webp" w-full alt="login_banner" />
</div> </div>
<div w-full f-c-c flex-col> <div w-320 flex-col px-20 py-35>
<h5 f-c-c w-full p-15 text-24 font-normal color="#6a6a6a"> <h5 f-c-c text-24 font-normal color="#6a6a6a"><icon-custom-logo mr-30 text-50 color-primary />{{ title }}</h5>
<icon-custom-logo mr30 text-50 /> <div mt-30>
{{ title }}
</h5>
<div mt-35 w-full max-w-360>
<n-input <n-input
v-model:value="loginInfo.name" v-model:value="loginInfo.name"
autofocus autofocus
class="text-16 items-center h-50 pl-10" class="text-16 items-center h-50 pl-10"
placeholder="请输入用户名" placeholder="admin"
:maxlength="20" :maxlength="20"
> />
</n-input>
</div> </div>
<div mt-35 w-full max-w-360> <div mt-30>
<n-input <n-input
v-model:value="loginInfo.password" v-model:value="loginInfo.password"
class="text-16 items-center h-50 pl-10" class="text-16 items-center h-50 pl-10"
type="password" type="password"
show-password-on="mousedown" show-password-on="mousedown"
placeholder="密码" placeholder="123456"
:maxlength="20" :maxlength="20"
@keydown.enter="handleLogin" @keydown.enter="handleLogin"
/> />
</div> </div>
<div mt-35 w-full max-w-360>
<n-button w-full h-50 rounded-5 text-16 type="primary" @click="handleLogin">登录</n-button> <div mt-20>
<n-checkbox :checked="isRemember" label="记住我" :on-update:checked="(val) => (isRemember = val)" />
</div>
<div mt-20>
<n-button w-full h-50 rounded-5 text-16 type="primary" :loading="loging" @click="handleLogin">
登录
</n-button>
</div> </div>
</div> </div>
</div> </div>
<AppFooter />
</div> </div>
</template> </template>
<script setup> <script setup>
import { login } from '@/api/auth' import { lStorage } from '@/utils/cache'
import { createLocalStorage } from '@/utils/cache'
import { setToken } from '@/utils/token' import { setToken } from '@/utils/token'
import { useStorage } from '@vueuse/core'
import bgImg from '@/assets/images/login_bg.webp'
import api from './api'
const title = import.meta.env.VITE_APP_TITLE const title = import.meta.env.VITE_TITLE
const router = useRouter() const router = useRouter()
const query = unref(router.currentRoute).query const { query } = useRoute()
const loginInfo = ref({ const loginInfo = ref({
name: 'admin', name: '',
password: '123456', password: '',
}) })
const ls = createLocalStorage({ prefixKey: 'login_' }) initLoginInfo()
const lsLoginInfo = ls.get('loginInfo')
if (lsLoginInfo) { function initLoginInfo() {
loginInfo.value.name = lsLoginInfo.name || '' const localLoginInfo = lStorage.get('loginInfo')
loginInfo.value.password = lsLoginInfo.password || '' if (localLoginInfo) {
loginInfo.value.name = localLoginInfo.name || ''
loginInfo.value.password = localLoginInfo.password || ''
}
} }
const isRemember = useStorage('isRemember', false)
const loging = ref(false)
async function handleLogin() { async function handleLogin() {
const { name, password } = loginInfo.value const { name, password } = loginInfo.value
if (!name || !password) { if (!name || !password) {
@@ -69,12 +80,16 @@ async function handleLogin() {
} }
try { try {
$message.loading('正在验证...') $message.loading('正在验证...')
const res = await login({ name, password: password.toString() }) loging.value = true
const res = await api.login({ name, password: password.toString() })
if (res.code === 0) { if (res.code === 0) {
$message.success('登录成功') $message.success('登录成功')
ls.set('loginInfo', { name, password })
setToken(res.data.token) setToken(res.data.token)
if (isRemember.value) {
lStorage.set('loginInfo', { name, password })
} else {
lStorage.remove('loginInfo')
}
if (query.redirect) { if (query.redirect) {
const path = query.redirect const path = query.redirect
Reflect.deleteProperty(query, 'redirect') Reflect.deleteProperty(query, 'redirect')
@@ -88,17 +103,6 @@ async function handleLogin() {
} catch (error) { } catch (error) {
$message.error(error.message) $message.error(error.message)
} }
loging.value = false
} }
</script> </script>
<style lang="scss" scoped>
.login-bg {
background-image: url(@/assets/images/login_bg.jpg);
background-size: cover;
}
.login-wrapper {
box-shadow: 1.5px 3.99px 27px 0px rgb(0 0 0 / 10%);
background-color: rgba(255, 255, 255, 0.6);
}
</style>

View File

@@ -1,21 +0,0 @@
<template></template>
<script setup>
const { currentRoute, replace } = useRouter()
const { query } = currentRoute.value
let { redirect } = query
Reflect.deleteProperty(query, 'redirect')
if (Array.isArray(redirect)) {
redirect = redirect.join('/')
}
if (redirect.startsWith('/redirect')) {
redirect = '/'
}
replace({
path: redirect.startsWith('/') ? redirect : '/' + redirect,
query,
})
</script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div p24> <div p24>
<n-button type="error" @click="handleDelete">删除</n-button> <n-button type="error" @click="handleDelete"> <icon-mi:delete mr-5 />删除</n-button>
</div> </div>
</template> </template>

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