Initial commit

This commit is contained in:
wzp 2023-04-10 21:53:03 +08:00
commit 7760d510e8
155 changed files with 11398 additions and 0 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

13
.env.development Normal file
View File

@ -0,0 +1,13 @@
# 请勿改动这一项,该项也不可以通过 import.meta.env.NODE_ENV 调用
NODE_ENV = development
# 下面是自定义的环境变量,可以修改(命名必须以 VITE_ 开头)
## 后端接口公共路径(如果解决跨域问题采用反向代理就只需写公共路径)
VITE_BASE_API = '/api/v1'
## 路由模式 hash 或 html5
VITE_ROUTER_HISTORY = 'hash'
## 开发环境地址前缀(一般 '/''./' 都可以)
VITE_PUBLIC_PATH = '/'

13
.env.production Normal file
View File

@ -0,0 +1,13 @@
# 请勿改动这一项,该项也不可以通过 import.meta.env.NODE_ENV 调用
NODE_ENV = production
# 下面是自定义的环境变量,可以修改(命名必须以 VITE_ 开头)
## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1'
## 路由模式 hash 或 html5
VITE_ROUTER_HISTORY = 'hash'
## 打包路径(就是网站前缀,例如部署到 https://un-pany.github.io/v3-admin-vite/ 域名下,就需要填写 /v3-admin-vite/
VITE_PUBLIC_PATH = '/v3-admin-vite/'

13
.env.staging Normal file
View File

@ -0,0 +1,13 @@
# 请勿改动这一项,该项也不可以通过 import.meta.env.NODE_ENV 调用
NODE_ENV = production
# 下面是自定义的环境变量,可以修改(命名必须以 VITE_ 开头)
## 后端接口公共路径(如果解决跨域问题采用 CORS 就需要写全路径)
VITE_BASE_API = 'https://mock.mengxuegu.com/mock/63218b5fb4c53348ed2bc212/api/v1'
## 路由模式 hash 或 html5
VITE_ROUTER_HISTORY = 'hash'
## 打包路径(就是网站前缀,例如部署到 https://un-pany.github.io/v3-admin-vite/ 域名下,就需要填写 /v3-admin-vite/
VITE_PUBLIC_PATH = '/v3-admin-vite/'

7
.eslintignore Normal file
View File

@ -0,0 +1,7 @@
# Eslint 会忽略的文件
.DS_Store
node_modules
dist
dist-ssr
*.local

85
.eslintrc.js Normal file
View File

@ -0,0 +1,85 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
es6: true
},
globals: {
// script setup
defineProps: "readonly",
defineEmits: "readonly",
defineExpose: "readonly",
withDefaults: "readonly",
// unplugin-vue-define-options
defineOptions: "readonly"
},
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/typescript/recommended",
"@vue/prettier",
"@vue/eslint-config-typescript"
// unplugin-auto-import 自动生成的文件
// "./types/.eslintrc-auto-import.json"
],
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
ecmaVersion: 2020,
sourceType: "module",
jsxPragma: "React",
ecmaFeatures: {
jsx: true,
tsx: true
}
},
rules: {
// TS
"@typescript-eslint/no-explicit-any": "off",
"no-debugger": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
],
"no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
],
// Vue
"vue/no-v-html": "off",
"vue/require-default-prop": "off",
"vue/require-explicit-emits": "off",
"vue/multi-word-component-names": "off",
"vue/html-self-closing": [
"error",
{
html: {
void: "always",
normal: "always",
component: "always"
},
svg: "always",
math: "always"
}
],
// Prettier
"prettier/prettier": [
"error",
{
endOfLine: "auto"
}
]
}
}

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

@ -0,0 +1,35 @@
name: Build And Deploy v3-admin-vite
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Setup Node.js 16.17.0
uses: actions/setup-node@master
with:
node-version: "16.17.0"
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: "7.30.5"
- name: Build
run: pnpm install && pnpm build:prod
- name: Deploy
uses: JamesIves/github-pages-deploy-action@releases/v3
with:
ACCESS_TOKEN: ${{ secrets.V3_ADMIN_VITE }}
BRANCH: gh-pages
FOLDER: dist

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Git 会忽略的文件
.DS_Store
node_modules
dist
dist-ssr
.eslintcache
# Local env files
*.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/*.code-snippets
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Use the PNPM
package-lock.json
yarn.lock

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

8
.prettierignore Normal file
View File

@ -0,0 +1,8 @@
# Prettier 会忽略的文件
.DS_Store
node_modules
dist
dist-ssr
*.local
*.d.ts

12
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"recommendations": [
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"vue.vscode-typescript-vue-plugin",
"vue.volar",
"antfu.unocss",
"zixuanchen.vitest-explorer",
"wiensss.region-highlighter"
]
}

16
.vscode/hook.code-snippets vendored Normal file
View File

@ -0,0 +1,16 @@
{
"Vue3 Hook 代码结构一键生成": {
"prefix": "Vue3 Hook",
"body": [
"import { ref } from \"vue\"\n",
"const refName1 = ref<string>(\"这是一个响应式变量\")\n",
"export function useHookName() {",
"\tconst refName2 = ref<string>(\"这是一个响应式变量\")\n",
"\tconst fnName = () => {}\n",
"\treturn { refName1, refName2, fnName }",
"}",
"$1"
],
"description": "Vue3 Hook"
}
}

30
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,30 @@
{
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

14
.vscode/vue.code-snippets vendored Normal file
View File

@ -0,0 +1,14 @@
{
"Vue3 SFC 代码结构一键生成": {
"prefix": "Vue3 SFC",
"body": [
"<script lang=\"ts\" setup></script>\n",
"<template>",
"\t<div class=\"app-container\">...</div>",
"</template>\n",
"<style scoped></style>",
"$1"
],
"description": "Vue3 SFC"
}
}

21
LICENSE Normal file
View File

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

160
README.md Normal file
View File

@ -0,0 +1,160 @@
<div align="center">
<img alt="V3 Admin Vite Logo" width="120" height="120" src="./src/assets/layout/logo.png">
<h1>V3 Admin Vite</h1>
<span>English | <a href="./README.zh-CN.md">中文</a></span>
</div>
## ⚡ Introduction
v3-admin-vite is a free and open source middle and background management system basic solution, based on mainstream framework such as Vue3, TypeScript, Element Plus, Pinia and Vite.
- Vue-Cli 5.x: [v3-admin](https://github.com/un-pany/v3-admin)
- Electron desktop: [v3-electron-vite](https://github.com/un-pany/v3-electron-vite)
## Feature
- **Vue3**The latest Vue3 composition API using Vue3 + script setup
- **Element Plus**Vue3 version of Element UI
- **Pinia**: An alternative to Vuex in Vue3
- **Vite**Really fast
- **Vue Router**router
- **TypeScript**JavaScript With Syntax For Types
- **PNPM**Faster, disk space saving package management tool
- **Scss**Consistent with Element Plus
- **CSS variable**Mainly controls the layout and color of the item
- **ESlint**Code verification
- **Prettier** Code formatting
- **Axios**: Promise based HTTP client (encapsulated)
- **UnoCSS**: Real-time atomized CSS engine with high performance and flexibility
- **Annotation**Each configuration item is written with as detailed comments as possible
- **Mobile Compatible**: The layout is compatible with mobile page resolution
## Functions
- **User management**: log in, log out of the demo
- **Authority management**: Built-in page permissions (dynamic routing), instruction permissions, permission functions
- **Multiple Environments**: Development, Staging, Production
- **Multiple themes**: Normal, Dark, Dark Blue, theme modes
- **Error page**: 403, 404
- **Dashboard**: Display different Dashboard pages according to different users
- **Other functions**SVG, Dynamic Sidebar, Dynamic Breadcrumb Navigation, Tabbed Navigation, Screenfull, Adaptive Shrink Sidebar
## 📚 Document
[Chinese documentation](https://juejin.cn/post/7089377403717287972)
[Chinese getting started tutorial](https://juejin.cn/column/7207659644487139387)
## Gitee repository
[Gitee](https://gitee.com/un-pany/v3-admin-vite)
## Online preview
| Location | account | Link |
| ------------ | ------------------- | ----------------------------------------------- |
| github-pages | `admin` or `editor` | [Link](https://un-pany.github.io/v3-admin-vite) |
## 🚀 Development
```bash
# configure
1. installation of the recommended plugins in the .vscode directory
3. node version 16+
4. pnpm version 7.x
# clone
git clone https://github.com/un-pany/v3-admin-vite.git
# enter the project directory
cd v3-admin-vite
# install dependencies
pnpm i
# start the service
pnpm dev
```
## ✔️ Preview
```bash
# stage environment
pnpm preview:stage
# prod environment
pnpm preview:prod
```
## 📦️ Multi-environment packaging
```bash
# build the stage environment
pnpm build:stage
# build the prod environment
pnpm build:prod
```
## 🔧 Code inspection
```bash
# code formatting
pnpm lint
# unit test
pnpm test
```
## Git commit specification reference
- `feat` add new functions
- `fix` Fix issues/bugs
- `perf` Optimize performance
- `style` Change the code style without affecting the running result
- `refactor` Re-factor code
- `revert` Undo changes
- `test` Test related, does not involve changes to business code
- `docs` Documentation and Annotation
- `chore` Updating dependencies/modifying scaffolding configuration, etc.
- `workflow` Work flow Improvements
- `ci` CICD
- `types` Type definition
- `wip` In development
## Project preview
![preview1.png](./src/assets/docs/preview1.png)
![preview2.png](./src/assets/docs/preview2.png)
![preview3.png](./src/assets/docs/preview3.png)
## 💕 Contributors
Thanks to all the contributors!
<a href="https://github.com/un-pany/v3-admin-vite/graphs/contributors">
<img src="https://contrib.rocks/image?repo=un-pany/v3-admin-vite" />
</a>
## 💕 Thanks for the sponsorship (the cost of sponsorship was used to send red envelopes in the group~)
| Name | Avatar |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| <a href="https://github.com/a3305278">a3305278</a> | <img src="https://avatars.githubusercontent.com/u/30458650?v=4" width="64px" height="64px" /> |
## 💕 Thanks star
Small projects are not easy to get a star, if you like this project, welcome to support a star! This is the only motivation for the author to maintain it on an ongoing basis (whisper: it's free after all)
## Group
QQ group1014374415 (left) && add me on WeChatInvite you to join WeChat group (right)
![qq.png](./src/assets/docs/qq.png)
![wechat.png](./src/assets/docs/wechat.png)
## 📄 License
[MIT](./LICENSE)
Copyright (c) 2022 [pany](https://github.com/pany-ang)

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

@ -0,0 +1,160 @@
<div align="center">
<img alt="V3 Admin Vite Logo" width="120" height="120" src="./src/assets/layout/logo.png">
<h1>V3 Admin Vite</h1>
<span><a href="./README.md">English</a> | 中文</span>
</div>
## ⚡ 简介
一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术.
- Vue-Cli 5.x 版: [v3-admin](https://github.com/un-pany/v3-admin)
- Electron 桌面版: [v3-electron-vite](https://github.com/un-pany/v3-electron-vite)
## 特性
- **Vue3**:采用 Vue3 + script setup 最新的 Vue3 组合式 API
- **Element Plus**Element UI 的 Vue3 版本
- **Pinia**: 传说中的 Vuex5
- **Vite**:真的很快
- **Vue Router**:路由路由
- **TypeScript**JavaScript 语言的超集
- **PNPM**:更快速的,节省磁盘空间的包管理工具
- **Scss**:和 Element Plus 保持一致
- **CSS 变量**:主要控制项目的布局和颜色
- **ESlint**:代码校验
- **Prettier**:代码格式化
- **Axios**:发送网络请求(已封装好)
- **UnoCSS**:具有高性能且极具灵活性的即时原子化 CSS 引擎
- **注释**:各个配置项都写有尽可能详细的注释
- **兼容移动端**: 布局兼容移动端页面分辨率
## 功能
- **用户管理**:登录、登出演示
- **权限管理**:内置页面权限(动态路由)、指令权限、权限函数、路由守卫
- **多环境**开发环境development、预发布环境staging、正式环境production
- **多主题**:内置普通、黑暗、深蓝三种主题模式
- **错误页面**: 403、404
- **Dashboard**:根据不同用户显示不同的 Dashboard 页面
- **其他内置功能**SVG、动态侧边栏、动态面包屑、标签页快捷导航、Screenfull 全屏、自适应收缩侧边栏
## 📚 文档
[中文文档](https://juejin.cn/post/7089377403717287972)
[手摸手教程](https://juejin.cn/column/7207659644487139387)
## 国内仓库
[Gitee](https://gitee.com/un-pany/v3-admin-vite)
## 在线预览
| 位置 | 账号 | 链接 |
| ------------ | --------------- | ----------------------------------------------- |
| github-pages | admin 或 editor | [链接](https://un-pany.github.io/v3-admin-vite) |
## 🚀 开发
```bash
# 配置
1. 一键安装 .vscode 目录中推荐的插件
3. node 版本 16+
4. pnpm 版本 7.x
# 克隆项目
git clone https://github.com/un-pany/v3-admin-vite.git
# 进入项目目录
cd v3-admin-vite
# 安装依赖
pnpm i
# 启动服务
pnpm dev
```
## ✔️ 预览
```bash
# 预览预发布环境
pnpm preview:stage
# 预览正式环境
pnpm preview:prod
```
## 📦️ 多环境打包
```bash
# 构建预发布环境
pnpm build:stage
# 构建正式环境
pnpm build:prod
```
## 🔧 代码检查
```bash
# 代码格式化
pnpm lint
# 单元测试
pnpm test
```
## Git 提交规范参考
- `feat` 增加新的业务功能
- `fix` 修复业务问题/BUG
- `perf` 优化性能
- `style` 更改代码风格, 不影响运行结果
- `refactor` 重构代码
- `revert` 撤销更改
- `test` 测试相关, 不涉及业务代码的更改
- `docs` 文档和注释相关
- `chore` 更新依赖/修改脚手架配置等琐事
- `workflow` 工作流改进
- `ci` 持续集成相关
- `types` 类型定义文件更改
- `wip` 开发中
## 项目预览图
![preview1.png](./src/assets/docs/preview1.png)
![preview2.png](./src/assets/docs/preview2.png)
![preview3.png](./src/assets/docs/preview3.png)
## 💕 贡献者
感谢所有的贡献者!
<a href="https://github.com/un-pany/v3-admin-vite/graphs/contributors">
<img src="https://contrib.rocks/image?repo=un-pany/v3-admin-vite" />
</a>
## 💕 感谢赞助(赞助的费用拿来在群里发红包了~
| 账号 | 头像 |
| -------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| <a href="https://github.com/a3305278">a3305278</a> | <img src="https://avatars.githubusercontent.com/u/30458650?v=4" width="64px" height="64px" /> |
## 💕 感谢 Star
小项目获取 star 不易,如果你喜欢这个项目的话,欢迎支持一个 star !这是作者持续维护的唯一动力(小声:毕竟是免费的)
## 可有可无的群
QQ 群1014374415&& 加我微信,拉你进微信群(右)
![qq.png](./src/assets/docs/qq.png)
![wechat.png](./src/assets/docs/wechat.png)
## 📄 License
[MIT](./LICENSE)
Copyright (c) 2022 [pany](https://github.com/pany-ang)

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/app-loading.css" />
<title>V3 Admin Vite</title>
</head>
<body>
<div id="app">
<div id="app-loading"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

106
package.json Normal file
View File

@ -0,0 +1,106 @@
{
"name": "v3-admin-vite",
"version": "3.3.4",
"description": "一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术.",
"author": {
"name": "pany",
"email": "939630029@qq.com",
"url": "https://github.com/pany-ang"
},
"repository": {
"type": "git",
"url": "https://github.com/un-pany/v3-admin-vite.git"
},
"scripts": {
"dev": "vite",
"build:stage": "vue-tsc --noEmit && vite build --mode staging",
"build:prod": "vue-tsc --noEmit && vite build",
"preview:stage": "pnpm build:stage && vite preview",
"preview:prod": "pnpm build:prod && vite preview",
"lint:eslint": "eslint --cache --max-warnings 0 \"src/**/*.{vue,js,ts,tsx}\" --fix",
"lint:prettier": "prettier --write \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint": "pnpm lint:eslint && pnpm lint:prettier",
"prepare": "husky install",
"test": "vitest"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.3.4",
"dayjs": "^1.11.7",
"element-plus": "^2.3.0",
"js-cookie": "^3.0.1",
"lodash-es": "^4.17.21",
"normalize.css": "^8.0.1",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"path-to-regexp": "^6.2.1",
"pinia": "^2.0.33",
"screenfull": "^6.0.2",
"vue": "^3.2.47",
"vue-router": "^4.1.6",
"vxe-table": "^4.3.10",
"vxe-table-plugin-element": "^3.0.6",
"xe-utils": "^3.5.7"
},
"devDependencies": {
"@types/js-cookie": "^3.0.3",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.15.3",
"@types/nprogress": "^0.2.0",
"@types/path-browserify": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vitejs/plugin-vue-jsx": "^3.0.0",
"@vue/eslint-config-prettier": "^7.1.0",
"@vue/eslint-config-typescript": "^11.0.2",
"@vue/test-utils": "^2.3.1",
"eslint": "^8.36.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.9.0",
"husky": "^8.0.3",
"jsdom": "^21.1.1",
"lint-staged": "^13.2.0",
"prettier": "^2.8.4",
"sass": "^1.59.3",
"terser": "^5.16.6",
"typescript": "^4.9.5",
"unocss": "^0.50.4",
"unplugin-vue-define-options": "^1.2.4",
"vite": "^4.1.4",
"vite-plugin-svg-icons": "^2.0.1",
"vite-svg-loader": "^4.0.0",
"vitest": "^0.29.3",
"vue-eslint-parser": "^9.1.0",
"vue-tsc": "^1.2.0"
},
"lint-staged": {
"*.{js,jsx,vue,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{scss,less,css,html,md}": [
"prettier --write"
],
"package.json": [
"prettier --write"
],
"{!(package)*.json,.!(browserslist)*rc}": [
"prettier --write--parser json"
]
},
"keywords": [
"vue",
"vue3",
"admin",
"vue-admin",
"vue3-admin",
"vite",
"vite-admin",
"element-plus",
"element-plus-admin",
"ts",
"typescript"
],
"license": "MIT"
}

5285
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

21
prettier.config.js Normal file
View File

@ -0,0 +1,21 @@
/** 配置项文档https://prettier.io/docs/en/configuration.html */
module.exports = {
/** 每一行的宽度 */
printWidth: 120,
/** Tab 键的空格数 */
tabWidth: 2,
/** 在对象中的括号之间是否用空格来间隔 */
bracketSpacing: true,
/** 箭头函数的参数无论有几个,都要括号包裹 */
arrowParens: "always",
/** 换行符的使用 */
endOfLine: "auto",
/** 是否采用单引号 */
singleQuote: false,
/** 对象或者数组的最后一个元素后面不要加逗号 */
trailingComma: "none",
/** 是否加分号 */
semi: false,
/** 是否使用 Tab 格式化 */
useTabs: false
}

65
public/app-loading.css Normal file
View File

@ -0,0 +1,65 @@
#app-loading,
#app-loading:before,
#app-loading:after {
border-radius: 50%;
width: 2.5em;
height: 2.5em;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation: loadingAnimation 1.8s infinite ease-in-out;
animation: loadingAnimation 1.8s infinite ease-in-out;
}
#app-loading {
color: #409eff;
font-size: 10px;
margin: 80px auto;
position: relative;
text-indent: -9999em;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
top: 0;
transform: translate(-50%, 0);
}
#app-loading:before,
#app-loading:after {
content: "";
position: absolute;
top: 0;
}
#app-loading:before {
left: -3.5em;
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
#app-loading:after {
left: 3.5em;
}
@-webkit-keyframes loadingAnimation {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}
@keyframes loadingAnimation {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

30
src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
import { h } from "vue"
import { useTheme } from "@/hooks/useTheme"
import { ElNotification } from "element-plus"
import zhCn from "element-plus/lib/locale/lang/zh-cn"
const { initTheme } = useTheme()
/** 初始化主题 */
initTheme()
/** 将 Element Plus 的语言设置为中文 */
const locale = zhCn
ElNotification({
title: "Hello",
message: h(
"a",
{ style: "color: teal", target: "_blank", href: "https://github.com/un-pany/v3-admin-vite" },
"小项目获取 star 不易,如果你喜欢这个项目的话,欢迎点击这里支持一个 star !这是作者持续维护的唯一动力(小声:毕竟是免费的)"
),
duration: 0,
position: "bottom-right"
})
</script>
<template>
<ElConfigProvider :locale="locale">
<router-view />
</ElConfigProvider>
</template>

View File

@ -0,0 +1,36 @@
/** 模拟接口响应数据 */
const SELECT_RESPONSE_DATA = {
code: 0,
data: [
{
label: "苹果",
value: 1
},
{
label: "香蕉",
value: 2
},
{
label: "橘子",
value: 3,
disabled: true
}
],
message: "获取 Select 数据成功"
}
/** 模拟接口 */
export function getSelectDataApi() {
return new Promise<typeof SELECT_RESPONSE_DATA>((resolve, reject) => {
// 模拟接口响应时间 2s
setTimeout(() => {
// 模拟接口调用成功
if (Math.random() < 0.8) {
resolve(SELECT_RESPONSE_DATA)
} else {
// 模拟接口调用出错
reject(new Error("接口发生错误"))
}
}, 2000)
})
}

View File

@ -0,0 +1,24 @@
/** 模拟接口响应数据 */
const SUCCESS_RESPONSE_DATA = {
code: 0,
data: {},
message: "获取成功"
}
/** 模拟请求接口成功 */
export function getSuccessApi() {
return new Promise<typeof SUCCESS_RESPONSE_DATA>((resolve) => {
setTimeout(() => {
resolve(SUCCESS_RESPONSE_DATA)
}, 1000)
})
}
/** 模拟请求接口失败 */
export function getErrorApi() {
return new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new Error("发生错误"))
}, 1000)
})
}

27
src/api/login/index.ts Normal file
View File

@ -0,0 +1,27 @@
import { request } from "@/utils/service"
import type * as Login from "./types/login"
/** 获取登录验证码 */
export function getLoginCodeApi() {
return request<Login.LoginCodeResponseData>({
url: "login/code",
method: "get"
})
}
/** 登录并返回 Token */
export function loginApi(data: Login.ILoginRequestData) {
return request<Login.LoginResponseData>({
url: "users/login",
method: "post",
data
})
}
/** 获取用户详情 */
export function getUserInfoApi() {
return request<Login.UserInfoResponseData>({
url: "users/info",
method: "get"
})
}

View File

@ -0,0 +1,14 @@
export interface ILoginRequestData {
/** admin 或 editor */
username: "admin" | "editor"
/** 密码 */
password: string
/** 验证码 */
code: string
}
export type LoginCodeResponseData = IApiResponseData<string>
export type LoginResponseData = IApiResponseData<{ token: string }>
export type UserInfoResponseData = IApiResponseData<{ username: string; roles: string[] }>

37
src/api/table/index.ts Normal file
View File

@ -0,0 +1,37 @@
import { request } from "@/utils/service"
import type * as Table from "./types/table"
/** 增 */
export function createTableDataApi(data: Table.ICreateTableRequestData) {
return request({
url: "table",
method: "post",
data
})
}
/** 删 */
export function deleteTableDataApi(id: string) {
return request({
url: `table/${id}`,
method: "delete"
})
}
/** 改 */
export function updateTableDataApi(data: Table.IUpdateTableRequestData) {
return request({
url: "table",
method: "put",
data
})
}
/** 查 */
export function getTableDataApi(params: Table.IGetTableRequestData) {
return request<Table.GetTableResponseData>({
url: "table",
method: "get",
params
})
}

View File

@ -0,0 +1,36 @@
export interface ICreateTableRequestData {
username: string
password: string
}
export interface IUpdateTableRequestData {
id: string
username: string
password?: string
}
export interface IGetTableRequestData {
/** 当前页码 */
currentPage: number
/** 查询条数 */
size: number
/** 查询参数:用户名 */
username?: string
/** 查询参数:手机号 */
phone?: string
}
export interface IGetTableData {
createTime: string
email: string
id: string
phone: string
roles: string
status: boolean
username: string
}
export type GetTableResponseData = IApiResponseData<{
list: IGetTableData[]
total: number
}>

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
src/assets/docs/qq.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/docs/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

BIN
src/assets/layout/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,60 @@
<script lang="ts" setup>
import { type PropType } from "vue"
import { type IListItem } from "./data"
const props = defineProps({
list: {
type: Object as PropType<IListItem[]>,
required: true
}
})
</script>
<template>
<el-empty v-if="props.list.length === 0" />
<el-card v-else v-for="(item, index) in props.list" :key="index" shadow="never" class="card-container">
<template #header>
<div class="card-header">
<div>
<span>
<span class="card-title">{{ item.title }}</span>
<el-tag v-if="item.extra" :type="item.status" effect="plain" size="small">{{ item.extra }}</el-tag>
</span>
<div class="card-time">{{ item.datetime }}</div>
</div>
<div v-if="item.avatar" class="card-avatar">
<img :src="item.avatar" width="34" />
</div>
</div>
</template>
<div class="card-body">
{{ item.description ?? "No Data" }}
</div>
</el-card>
</template>
<style lang="scss" scoped>
.card-container {
margin-bottom: 10px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-title {
font-weight: bold;
margin-right: 10px;
}
.card-time {
font-size: 12px;
color: grey;
}
.card-avatar {
display: flex;
align-items: center;
}
}
.card-body {
font-size: 12px;
}
}
</style>

View File

@ -0,0 +1,66 @@
export interface IListItem {
avatar?: string
title: string
datetime?: string
description?: string
status?: "" | "success" | "info" | "warning" | "danger"
extra?: string
}
export const notifyData: IListItem[] = [
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
title: "V3 Admin Vite 上线啦",
datetime: "半年前",
description:
"一个免费开源的中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus、Pinia 和 Vite 等主流技术"
},
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
title: "V3 Admin 上线啦",
datetime: "一年前",
description: "一个中后台管理系统基础解决方案,基于 Vue3、TypeScript、Element Plus 和 Pinia"
}
]
export const messageData: IListItem[] = [
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "来自楚门的世界",
description: "如果再也不能见到你,祝你早安、午安和晚安",
datetime: "1998-06-05"
},
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "来自大话西游",
description: "如果非要在这份爱上加上一个期限,我希望是一万年",
datetime: "1995-02-04"
},
{
avatar: "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
title: "来自龙猫",
description: "心存善意,定能途遇天使",
datetime: "1988-04-16"
}
]
export const todoData: IListItem[] = [
{
title: "任务名称",
description: "这家伙很懒,什么都没留下",
extra: "未开始",
status: "info"
},
{
title: "任务名称",
description: "这家伙很懒,什么都没留下",
extra: "进行中",
status: ""
},
{
title: "任务名称",
description: "这家伙很懒,什么都没留下",
extra: "已超时",
status: "danger"
}
]

View File

@ -0,0 +1,99 @@
<script lang="ts" setup>
import { ref, computed } from "vue"
import { ElMessage } from "element-plus"
import { Bell } from "@element-plus/icons-vue"
import NotifyList from "./NotifyList.vue"
import { type IListItem, notifyData, messageData, todoData } from "./data"
type TabNameType = "通知" | "消息" | "待办"
interface IDataItem {
name: TabNameType
type: "primary" | "success" | "warning" | "danger" | "info"
list: IListItem[]
}
/** 角标当前值 */
const badgeValue = computed(() => {
let value = 0
for (let i = 0; i < data.value.length; i++) {
value += data.value[i].list.length
}
return value
})
/** 角标最大值 */
const badgeMax = 99
/** 面板宽度 */
const popoverWidth = 350
/** 当前 Tab */
const activeName = ref<TabNameType>("通知")
/** 所有数据 */
const data = ref<IDataItem[]>([
//
{
name: "通知",
type: "primary",
list: notifyData
},
//
{
name: "消息",
type: "danger",
list: messageData
},
//
{
name: "待办",
type: "warning",
list: todoData
}
])
const handleHistory = () => {
ElMessage.success(`跳转到${activeName.value}历史页面`)
}
</script>
<template>
<div class="notify">
<el-popover placement="bottom" :width="popoverWidth" trigger="click">
<template #reference>
<el-badge :value="badgeValue" :max="badgeMax" :hidden="badgeValue === 0">
<el-tooltip effect="dark" content="消息通知" placement="bottom">
<el-icon :size="20">
<Bell />
</el-icon>
</el-tooltip>
</el-badge>
</template>
<template #default>
<el-tabs v-model="activeName" class="demo-tabs" stretch>
<el-tab-pane v-for="(item, index) in data" :name="item.name" :key="index">
<template #label>
{{ item.name }}
<el-badge :value="item.list.length" :max="badgeMax" :type="item.type" />
</template>
<el-scrollbar height="400px">
<NotifyList :list="item.list" />
</el-scrollbar>
</el-tab-pane>
</el-tabs>
<div class="notify-history">
<el-button link @click="handleHistory">查看{{ activeName }}历史</el-button>
</div>
</template>
</el-popover>
</div>
</template>
<style lang="scss" scoped>
.notify {
margin-right: 10px;
color: var(--el-text-color-regular);
}
.notify-history {
text-align: center;
padding-top: 12px;
border-top: 1px solid var(--el-border-color);
}
</style>

View File

@ -0,0 +1,65 @@
<script lang="ts" setup>
import { ref, onUnmounted } from "vue"
import { ElMessage } from "element-plus"
import screenfull from "screenfull"
const props = defineProps({
/** 全屏的元素,默认是 html */
element: {
type: String,
default: "html"
},
/** 打开全屏提示语 */
openTips: {
type: String,
default: "全屏"
},
/** 关闭全屏提示语 */
exitTips: {
type: String,
default: "退出全屏"
}
})
const tips = ref<string>(props.openTips)
const isFullscreen = ref<boolean>(false)
const click = () => {
const dom = document.querySelector(props.element) || undefined
if (!screenfull.isEnabled) {
ElMessage.warning("您的浏览器无法工作")
return
}
screenfull.toggle(dom)
}
const change = () => {
isFullscreen.value = screenfull.isFullscreen
tips.value = screenfull.isFullscreen ? props.exitTips : props.openTips
}
screenfull.on("change", change)
onUnmounted(() => {
if (screenfull.isEnabled) {
screenfull.off("change", change)
}
})
</script>
<template>
<div @click="click">
<el-tooltip effect="dark" :content="tips" placement="bottom">
<svg-icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
</el-tooltip>
</div>
</template>
<style lang="scss" scoped>
.svg-icon {
font-size: 20px;
&:focus {
outline: none;
}
}
</style>

View File

@ -0,0 +1,31 @@
<script lang="ts" setup>
import { computed } from "vue"
const props = defineProps({
prefix: {
type: String,
default: "icon"
},
name: {
type: String,
required: true
}
})
const symbolId = computed(() => `#${props.prefix}-${props.name}`)
</script>
<template>
<svg class="svg-icon" aria-hidden="true">
<use :href="symbolId" />
</svg>
</template>
<style lang="scss" scoped>
.svg-icon {
width: 1em;
height: 1em;
fill: currentColor;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { type ThemeName, useTheme } from "@/hooks/useTheme"
import { MagicStick } from "@element-plus/icons-vue"
const { themeList, activeThemeName, setTheme } = useTheme()
const handleSetTheme = (name: ThemeName) => {
setTheme(name)
}
</script>
<template>
<el-dropdown trigger="click" @command="handleSetTheme">
<div>
<el-tooltip effect="dark" content="主题模式" placement="bottom">
<el-icon :size="20">
<MagicStick />
</el-icon>
</el-tooltip>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(theme, index) in themeList"
:key="index"
:disabled="activeThemeName === theme.name"
:command="theme.name"
>
<span>{{ theme.title }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>

21
src/config/async-route.ts Normal file
View File

@ -0,0 +1,21 @@
/** 动态路由配置 */
interface IAsyncRouteSettings {
/**
*
* 1. roles
* 2. open: false
*/
open: boolean
/**
* 1. 访
* 2.
*/
defaultRoles: Array<string>
}
const asyncRouteSettings: IAsyncRouteSettings = {
open: true,
defaultRoles: ["DEFAULT_ROLE"]
}
export default asyncRouteSettings

35
src/config/layout.ts Normal file
View File

@ -0,0 +1,35 @@
/** 布局配置 */
interface ILayoutSettings {
/** 是否显示 Settings Panel */
showSettings: boolean
/** 是否显示标签栏 */
showTagsView: boolean
/** 是否显示侧边栏 Logo */
showSidebarLogo: boolean
/** 是否固定 Header */
fixedHeader: boolean
/** 是否显示消息通知 */
showNotify: boolean
/** 是否显示切换主题按钮 */
showThemeSwitch: boolean
/** 是否显示全屏按钮 */
showScreenfull: boolean
/** 是否显示灰色模式 */
showGreyMode: boolean
/** 是否显示色弱模式 */
showColorWeakness: boolean
}
const layoutSettings: ILayoutSettings = {
showSettings: true,
showTagsView: true,
fixedHeader: true,
showSidebarLogo: true,
showNotify: true,
showThemeSwitch: true,
showScreenfull: true,
showGreyMode: false,
showColorWeakness: false
}
export default layoutSettings

4
src/config/white-list.ts Normal file
View File

@ -0,0 +1,4 @@
/** 免登录白名单 */
const whiteList = ["/login"]
export { whiteList }

10
src/constants/cacheKey.ts Normal file
View File

@ -0,0 +1,10 @@
const SYSTEM_NAME = "v3-admin-vite"
/** 缓存数据时用到的 Key */
class CacheKey {
static TOKEN = `${SYSTEM_NAME}-token-key`
static SIDEBAR_STATUS = `${SYSTEM_NAME}-sidebar-status-key`
static ACTIVE_THEME_NAME = `${SYSTEM_NAME}-active-theme-name-key`
}
export default CacheKey

7
src/directives/index.ts Normal file
View File

@ -0,0 +1,7 @@
import { type App } from "vue"
import { permission } from "./permission"
/** 挂载自定义指令 */
export function loadDirectives(app: App) {
app.directive("permission", permission)
}

View File

@ -0,0 +1,21 @@
import { type Directive } from "vue"
import { useUserStoreHook } from "@/store/modules/user"
/** 权限指令 */
export const permission: Directive = {
mounted(el, binding) {
const { value } = binding
const roles = useUserStoreHook().roles
if (value && value instanceof Array && value.length > 0) {
const permissionRoles = value
const hasPermission = roles.some((role) => {
return permissionRoles.includes(role)
})
if (!hasPermission) {
el.style.display = "none"
}
} else {
throw new Error(`need roles! Like v-permission="['admin','editor']"`)
}
}
}

View File

@ -0,0 +1,53 @@
import { ref, onMounted } from "vue"
type OptionValueType = string | number
/** Select 需要的数据格式 */
interface ISelectOption {
value: OptionValueType
label: string
disabled?: boolean
}
/** 接口响应格式 */
interface IApiData {
code: number
data: ISelectOption[]
message: string
}
/** 入参格式,暂时只需要传递 api 函数即可 */
interface IFetchSelectProps {
api: () => Promise<IApiData>
}
export function useFetchSelect(props: IFetchSelectProps) {
const { api } = props
const loading = ref<boolean>(false)
const options = ref<ISelectOption[]>([])
const value = ref<OptionValueType>("")
/** 调用接口获取数据 */
const loadData = () => {
loading.value = true
options.value = []
api()
.then((res) => {
options.value = res.data
})
.finally(() => {
loading.value = false
})
}
onMounted(() => {
loadData()
})
return {
loading,
options,
value
}
}

View File

@ -0,0 +1,63 @@
import { type LoadingOptions, ElLoading } from "element-plus"
const defaultOptions = {
lock: true,
text: "加载中..."
}
interface ILoadingInstance {
close: () => void
}
interface IUseFullscreenLoading {
<T extends (...args: any[]) => ReturnType<T>>(fn: T, options?: LoadingOptions): (
...args: Parameters<T>
) => Promise<ReturnType<T>> | ReturnType<T>
}
/**
* fnloading
*
* 1. fn loading
* 2. fn Promiseresolve reject loading
* 3. loading
* @param {*} fn
* @param options LoadingOptions
* @returns Function
*/
export const useFullscreenLoading: IUseFullscreenLoading = (fn, options = {}) => {
let loadingInstance: ILoadingInstance
const showLoading = (options: LoadingOptions) => {
loadingInstance = ElLoading.service(options)
}
const hideLoading = () => {
loadingInstance && loadingInstance.close()
}
const _options = { ...defaultOptions, ...options }
return (...args) => {
try {
showLoading(_options)
const result = fn(...args)
const isPromise = result instanceof Promise
// 同步函数
if (!isPromise) {
hideLoading()
return Promise.resolve(result)
}
// Promise
return result
.then((res) => {
return res
})
.catch((err) => {
throw err
})
.finally(() => {
hideLoading()
})
} catch (err) {
hideLoading()
throw err
}
}
}

View File

@ -0,0 +1,43 @@
import { reactive } from "vue"
interface IDefaultPaginationData {
total: number
currentPage: number
pageSizes: number[]
pageSize: number
layout: string
}
interface IPaginationData {
total?: number
currentPage?: number
pageSizes?: number[]
pageSize?: number
layout?: string
}
/** 默认的分页参数 */
const defaultPaginationData: IDefaultPaginationData = {
total: 0,
currentPage: 1,
pageSizes: [10, 20, 50],
pageSize: 10,
layout: "total, sizes, prev, pager, next, jumper"
}
export function usePagination(_paginationData: IPaginationData = {}) {
/** 合并分页参数 */
const paginationData = reactive(Object.assign({ ...defaultPaginationData }, _paginationData))
/** 改变当前页码 */
const handleCurrentChange = (value: number) => {
paginationData.currentPage = value
}
/** 改变页面大小 */
const handleSizeChange = (value: number) => {
paginationData.pageSize = value
}
return { paginationData, handleCurrentChange, handleSizeChange }
}

54
src/hooks/useTheme.ts Normal file
View File

@ -0,0 +1,54 @@
import { ref, watchEffect } from "vue"
import { getActiveThemeName, setActiveThemeName } from "@/utils/cache/localStorage"
const DEFAULT_THEME_NAME = "normal"
type DefaultThemeNameType = typeof DEFAULT_THEME_NAME
/** 注册的主题名称, 其中 DefaultThemeNameType 是必填的 */
export type ThemeName = DefaultThemeNameType | "dark" | "dark-blue"
interface IThemeList {
title: string
name: ThemeName
}
/** 主题列表 */
const themeList: IThemeList[] = [
{
title: "默认",
name: DEFAULT_THEME_NAME
},
{
title: "黑暗",
name: "dark"
},
{
title: "深蓝",
name: "dark-blue"
}
]
/** 正在应用的主题名称 */
const activeThemeName = ref<ThemeName>(getActiveThemeName() || DEFAULT_THEME_NAME)
const setTheme = (value: ThemeName) => {
activeThemeName.value = value
}
/** 在 html 根元素上挂载 class */
const setHtmlClassName = (value: ThemeName) => {
document.documentElement.className = value
}
const initTheme = () => {
watchEffect(() => {
const value = activeThemeName.value
setHtmlClassName(value)
setActiveThemeName(value)
})
}
/** 主题 hook */
export function useTheme() {
return { themeList, activeThemeName, initTheme, setTheme }
}

7
src/icons/index.ts Normal file
View File

@ -0,0 +1,7 @@
import { type App } from "vue"
import SvgIcon from "@/components/SvgIcon/index.vue" // Svg Component
import "virtual:svg-icons-register"
export function loadSvg(app: App) {
app.component("SvgIcon", SvgIcon)
}

1
src/icons/svg/404.svg Normal file
View File

@ -0,0 +1 @@
<svg t="1651119499039" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9021" width="200" height="200"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="9022"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="9023"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="9024"></path></svg>

After

Width:  |  Height:  |  Size: 548 B

1
src/icons/svg/bug.svg Normal file
View File

@ -0,0 +1 @@
<svg t="1651119031318" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8881" width="200" height="200"><path d="M940 512H792V412c76.8 0 139-62.2 139-139 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 34.8-28.2 63-63 63H232c-34.8 0-63-28.2-63-63 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 76.8 62.2 139 139 139v100H84c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h148v96c0 6.5 0.2 13 0.7 19.3C164.1 728.6 116 796.7 116 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-44.2 23.9-82.9 59.6-103.7 6 17.2 13.6 33.6 22.7 49 24.3 41.5 59 76.2 100.5 100.5S460.5 960 512 960s99.8-13.9 141.3-38.2c41.5-24.3 76.2-59 100.5-100.5 9.1-15.5 16.7-31.9 22.7-49C812.1 793.1 836 831.8 836 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-79.3-48.1-147.4-116.7-176.7 0.4-6.4 0.7-12.8 0.7-19.3v-96h148c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM716 680c0 36.8-9.7 72-27.8 102.9-17.7 30.3-43 55.6-73.3 73.3-20.1 11.8-42 20-64.9 24.3V484c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8v396.5c-22.9-4.3-44.8-12.5-64.9-24.3-30.3-17.7-55.6-43-73.3-73.3C317.7 752 308 716.8 308 680V412h408v268z" p-id="8882"></path><path d="M304 280h56c4.4 0 8-3.6 8-8 0-28.3 5.9-53.2 17.1-73.5 10.6-19.4 26-34.8 45.4-45.4C450.9 142 475.7 136 504 136h16c28.3 0 53.2 5.9 73.5 17.1 19.4 10.6 34.8 26 45.4 45.4C650 218.9 656 243.7 656 272c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-40-8.8-76.7-25.9-108.1-17.2-31.5-42.5-56.8-74-74C596.7 72.8 560 64 520 64h-16c-40 0-76.7 8.8-108.1 25.9-31.5 17.2-56.8 42.5-74 74C304.8 195.3 296 232 296 272c0 4.4 3.6 8 8 8z" p-id="8883"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1672728665955" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3482" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16"><path d="M64 64h384v384H64V64z m0 512h384v384H64V576z m512 0h384v384H576V576z m192-128c106.039 0 192-85.961 192-192S874.039 64 768 64s-192 85.961-192 192 85.961 192 192 192z" p-id="3483"></path></svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@ -0,0 +1 @@
<svg t="1651118937898" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8601" width="200" height="200"><path d="M924.8 385.6c-22.6-53.4-54.9-101.3-96-142.4-41.1-41.1-89-73.4-142.4-96C631.1 123.8 572.5 112 512 112s-119.1 11.8-174.4 35.2c-53.4 22.6-101.3 54.9-142.4 96-41.1 41.1-73.4 89-96 142.4C75.8 440.9 64 499.5 64 560c0 132.7 58.3 257.7 159.9 343.1l1.7 1.4c5.8 4.8 13.1 7.5 20.6 7.5h531.7c7.5 0 14.8-2.7 20.6-7.5l1.7-1.4C901.7 817.7 960 692.7 960 560c0-60.5-11.9-119.1-35.2-174.4zM761.4 836H262.6C184.5 765.5 140 665.6 140 560c0-99.4 38.7-192.8 109-263 70.3-70.3 163.7-109 263-109 99.4 0 192.8 38.7 263 109 70.3 70.3 109 163.7 109 263 0 105.6-44.5 205.5-122.6 276z" p-id="8602"></path><path d="M623.5 421.5c-3.1-3.1-8.2-3.1-11.3 0L527.7 506c-18.7-5-39.4-0.2-54.1 14.5-21.9 21.9-21.9 57.3 0 79.2 21.9 21.9 57.3 21.9 79.2 0 14.7-14.7 19.5-35.4 14.5-54.1l84.5-84.5c3.1-3.1 3.1-8.2 0-11.3l-28.3-28.3zM490 320h44c4.4 0 8-3.6 8-8v-80c0-4.4-3.6-8-8-8h-44c-4.4 0-8 3.6-8 8v80c0 4.4 3.6 8 8 8zM750 538v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8h-80c-4.4 0-8 3.6-8 8zM762.7 340.8l-31.1-31.1c-3.1-3.1-8.2-3.1-11.3 0l-56.6 56.6c-3.1 3.1-3.1 8.2 0 11.3l31.1 31.1c3.1 3.1 8.2 3.1 11.3 0l56.6-56.6c3.1-3.1 3.1-8.2 0-11.3zM304.1 309.7c-3.1-3.1-8.2-3.1-11.3 0l-31.1 31.1c-3.1 3.1-3.1 8.2 0 11.3l56.6 56.6c3.1 3.1 8.2 3.1 11.3 0l31.1-31.1c3.1-3.1 3.1-8.2 0-11.3l-56.6-56.6zM262 530h-80c-4.4 0-8 3.6-8 8v44c0 4.4 3.6 8 8 8h80c4.4 0 8-3.6 8-8v-44c0-4.4-3.6-8-8-8z" p-id="8603"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg t="1661153147729" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3352" width="200" height="200"><path d="M704 864v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m-256 704v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64-704v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3353"></path></svg>

After

Width:  |  Height:  |  Size: 747 B

View File

@ -0,0 +1 @@
<svg t="1661151768669" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3212" width="200" height="200"><path d="M192 384v-96c0-54.4 41.6-96 96-96h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-89.6 0-160 70.4-160 160v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 89.6 70.4 160 160 160h96c19.2 0 32-12.8 32-32s-12.8-32-32-32h-96c-54.4 0-96-41.6-96-96v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z m768-256v-96c0-89.6-70.4-160-160-160h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c54.4 0 96 41.6 96 96v96c0 19.2 12.8 32 32 32s32-12.8 32-32z m-64 256v96c0 54.4-41.6 96-96 96h-96c-19.2 0-32 12.8-32 32s12.8 32 32 32h96c89.6 0 160-70.4 160-160v-96c0-19.2-12.8-32-32-32s-32 12.8-32 32z" p-id="3213"></path></svg>

After

Width:  |  Height:  |  Size: 746 B

1
src/icons/svg/link.svg Normal file
View File

@ -0,0 +1 @@
<svg t="1651118878747" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8461" width="200" height="200"><path d="M574 665.4c-3.1-3.1-8.2-3.1-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8c-3.1-3.1-8.2-3.1-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zM832.6 191.4c-84.6-84.6-221.5-84.6-306 0L410.3 307.6c-3.1 3.1-3.1 8.2 0 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6c-3.1 3.1-3.1 8.2 0 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1z" p-id="8462"></path><path d="M610.1 372.3c-3.1-3.1-8.2-3.1-11.3 0L372.3 598.7c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z" p-id="8463"></path></svg>

After

Width:  |  Height:  |  Size: 925 B

1
src/icons/svg/lock.svg Normal file
View File

@ -0,0 +1 @@
<svg t="1651119007904" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8741" width="200" height="200"><path d="M832 464h-68V240c0-70.7-57.3-128-128-128H388c-70.7 0-128 57.3-128 128v224h-68c-17.7 0-32 14.3-32 32v384c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V496c0-17.7-14.3-32-32-32zM332 240c0-30.9 25.1-56 56-56h248c30.9 0 56 25.1 56 56v224H332V240z m460 600H232V536h560v304z" p-id="8742"></path><path d="M484 701v53c0 4.4 3.6 8 8 8h40c4.4 0 8-3.6 8-8v-53c12.1-8.7 20-22.9 20-39 0-26.5-21.5-48-48-48s-48 21.5-48 48c0 16.1 7.9 30.3 20 39z" p-id="8743"></path></svg>

After

Width:  |  Height:  |  Size: 613 B

1
src/icons/svg/menu.svg Normal file
View File

@ -0,0 +1 @@
<svg t="1651750906395" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9162" width="200" height="200"><path d="M904 158H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 582H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 794H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM904 370H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="9163"></path></svg>

After

Width:  |  Height:  |  Size: 539 B

11
src/icons/svg/unocss.svg Normal file
View File

@ -0,0 +1,11 @@
<svg width="220" height="220" viewBox="0 0 220 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M117.722 167.444C117.722 139.83 140.108 117.444 167.722 117.444V117.444C195.336 117.444 217.722 139.83 217.722 167.444V167.444C217.722 195.058 195.336 217.444 167.722 217.444V217.444C140.108 217.444 117.722 195.058 117.722 167.444V167.444Z"
fill="#fff" fill-opacity="0.6" />
<path
d="M117.722 52.5561C117.722 24.9419 140.108 2.55614 167.722 2.55614V2.55614C195.336 2.55614 217.722 24.9419 217.722 52.5561V97.5561C217.722 100.318 215.483 102.556 212.722 102.556H122.722C119.961 102.556 117.722 100.318 117.722 97.5561V52.5561Z"
fill="#fff" fill-opacity="0.3" />
<path
d="M102.278 167.444C102.278 195.058 79.8922 217.444 52.278 217.444V217.444C24.6637 217.444 2.27796 195.058 2.27796 167.444L2.27796 122.444C2.27796 119.682 4.51654 117.444 7.27796 117.444L97.278 117.444C100.039 117.444 102.278 119.682 102.278 122.444L102.278 167.444Z"
fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@ -0,0 +1,49 @@
<script lang="ts" setup>
import { computed } from "vue"
import { useRoute } from "vue-router"
import { useTagsViewStore } from "@/store/modules/tags-view"
const route = useRoute()
const tagsViewStore = useTagsViewStore()
const key = computed(() => {
return route.path
})
</script>
<template>
<section class="app-main">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="tagsViewStore.cachedViews">
<component :is="Component" :key="key" />
</keep-alive>
</transition>
</router-view>
</section>
</template>
<style lang="scss" scoped>
.app-main {
min-height: calc(100vh - var(--v3-navigationbar-height));
width: 100%;
position: relative;
overflow: hidden;
background-color: var(--v3-body-bg-color);
}
.fixed-header + .app-main {
padding-top: var(--v3-navigationbar-height);
height: 100vh;
overflow: auto;
}
.hasTagsView {
.app-main {
min-height: calc(100vh - var(--v3-header-height));
}
.fixed-header + .app-main {
padding-top: var(--v3-header-height);
}
}
</style>

View File

@ -0,0 +1,74 @@
<script lang="ts" setup>
import { ref, watch } from "vue"
import { type RouteLocationMatched, useRoute, useRouter } from "vue-router"
import { compile } from "path-to-regexp"
const route = useRoute()
const router = useRouter()
const breadcrumbs = ref<RouteLocationMatched[]>([])
const getBreadcrumb = () => {
breadcrumbs.value = route.matched.filter((item) => {
return item.meta && item.meta.title && item.meta.breadcrumb !== false
})
}
const pathCompile = (path: string) => {
const { params } = route
const toPath = compile(path)
return toPath(params)
}
const handleLink = (item: RouteLocationMatched) => {
const { redirect, path } = item
if (redirect) {
router.push(redirect as string)
return
}
router.push(pathCompile(path))
}
watch(
() => route.path,
(path) => {
if (path.startsWith("/redirect/")) {
return
}
getBreadcrumb()
}
)
getBreadcrumb()
</script>
<template>
<el-breadcrumb class="app-breadcrumb">
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
<span v-if="item.redirect === 'noRedirect' || index === breadcrumbs.length - 1" class="no-redirect">
{{ item.meta.title }}
</span>
<a v-else @click.prevent="handleLink(item)">
{{ item.meta.title }}
</a>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<style lang="scss" scoped>
.el-breadcrumb__inner,
.el-breadcrumb__inner a {
font-weight: 400 !important;
}
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: var(--v3-navigationbar-height);
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
import { Expand, Fold } from "@element-plus/icons-vue"
const props = defineProps({
isActive: {
type: Boolean,
default: false
}
})
const emit = defineEmits<{
(e: "toggle-click"): void
}>()
const toggleClick = () => {
emit("toggle-click")
}
</script>
<template>
<div @click="toggleClick">
<el-icon :size="20" class="icon">
<Fold v-if="props.isActive" />
<Expand v-else />
</el-icon>
</div>
</template>
<style lang="scss" scoped>
.icon {
vertical-align: middle;
}
</style>

View File

@ -0,0 +1,118 @@
<script lang="ts" setup>
import { computed } from "vue"
import { useRouter } from "vue-router"
import { useAppStore } from "@/store/modules/app"
import { useSettingsStore } from "@/store/modules/settings"
import { useUserStore } from "@/store/modules/user"
import { UserFilled } from "@element-plus/icons-vue"
import Breadcrumb from "../Breadcrumb/index.vue"
import Hamburger from "../Hamburger/index.vue"
import ThemeSwitch from "@/components/ThemeSwitch/index.vue"
import Screenfull from "@/components/Screenfull/index.vue"
import Notify from "@/components/Notify/index.vue"
const router = useRouter()
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const sidebar = computed(() => {
return appStore.sidebar
})
const showNotify = computed(() => {
return settingsStore.showNotify
})
const showThemeSwitch = computed(() => {
return settingsStore.showThemeSwitch
})
const showScreenfull = computed(() => {
return settingsStore.showScreenfull
})
const toggleSidebar = () => {
appStore.toggleSidebar(false)
}
const logout = () => {
userStore.logout()
router.push("/login")
}
</script>
<template>
<div class="navigation-bar">
<Hamburger :is-active="sidebar.opened" class="hamburger" @toggle-click="toggleSidebar" />
<Breadcrumb class="breadcrumb" />
<div class="right-menu">
<Screenfull v-if="showScreenfull" class="right-menu-item" />
<ThemeSwitch v-if="showThemeSwitch" class="right-menu-item" />
<Notify v-if="showNotify" class="right-menu-item" />
<el-dropdown class="right-menu-item">
<div class="right-menu-avatar">
<el-avatar :icon="UserFilled" :size="30" />
<span>{{ userStore.username }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<a target="_blank" href="https://juejin.cn/post/7089377403717287972">
<el-dropdown-item>中文文档</el-dropdown-item>
</a>
<a target="_blank" href="https://github.com/un-pany/v3-admin-vite">
<el-dropdown-item>GitHub</el-dropdown-item>
</a>
<a target="_blank" href="https://gitee.com/un-pany/v3-admin-vite">
<el-dropdown-item>Gitee</el-dropdown-item>
</a>
<el-dropdown-item divided @click="logout">
<span style="display: block">退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<style lang="scss" scoped>
.navigation-bar {
height: var(--v3-navigationbar-height);
overflow: hidden;
background: #fff;
.hamburger {
display: flex;
align-items: center;
height: 100%;
float: left;
padding: 0 15px;
cursor: pointer;
}
.breadcrumb {
float: left;
// Bootstrap WIDTH = 576
@media screen and (max-width: 576px) {
display: none;
}
}
.right-menu {
float: right;
margin-right: 10px;
height: 100%;
display: flex;
align-items: center;
color: #606266;
.right-menu-item {
padding: 0 10px;
cursor: pointer;
.right-menu-avatar {
display: flex;
align-items: center;
.el-avatar {
margin-right: 10px;
}
span {
font-size: 16px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts" setup>
import { ref } from "vue"
import { Setting } from "@element-plus/icons-vue"
const props = defineProps({
buttonTop: {
type: Number,
default: 350
}
})
const buttonTopCss = props.buttonTop + "px"
const show = ref(false)
</script>
<template>
<div class="handle-button" @click="show = true">
<el-icon :size="24">
<Setting />
</el-icon>
</div>
<el-drawer v-model="show" size="300px" :with-header="false">
<slot />
</el-drawer>
</template>
<style lang="scss" scoped>
.handle-button {
width: 48px;
height: 48px;
background-color: var(--v3-rightpanel-button-bg-color);
position: fixed;
top: v-bind(buttonTopCss);
right: 0px;
border-radius: 6px 0 0 6px;
z-index: 10;
cursor: pointer;
pointer-events: auto;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,66 @@
<script lang="ts" setup>
import { useSettingsStore } from "@/store/modules/settings"
const settingsStore = useSettingsStore()
</script>
<template>
<div class="drawer-container">
<div>
<h3 class="drawer-title">系统布局配置</h3>
<div class="drawer-item">
<span>显示标签栏</span>
<el-switch v-model="settingsStore.showTagsView" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示侧边栏 Logo</span>
<el-switch v-model="settingsStore.showSidebarLogo" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>固定 Header</span>
<el-switch v-model="settingsStore.fixedHeader" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示消息通知</span>
<el-switch v-model="settingsStore.showNotify" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示切换主题按钮</span>
<el-switch v-model="settingsStore.showThemeSwitch" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示全屏按钮</span>
<el-switch v-model="settingsStore.showScreenfull" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示灰色模式</span>
<el-switch v-model="settingsStore.showGreyMode" class="drawer-switch" />
</div>
<div class="drawer-item">
<span>显示色弱模式</span>
<el-switch v-model="settingsStore.showColorWeakness" class="drawer-switch" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.drawer-container {
padding: 24px;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
.drawer-title {
margin-bottom: 12px;
font-size: 14px;
line-height: 22px;
}
.drawer-item {
font-size: 14px;
padding: 12px 0;
}
.drawer-switch {
float: right;
}
}
</style>

View File

@ -0,0 +1,126 @@
<script lang="ts" setup>
import { type PropType, computed } from "vue"
import { type RouteRecordRaw } from "vue-router"
import SidebarItemLink from "./SidebarItemLink.vue"
import { isExternal } from "@/utils/validate"
import path from "path-browserify"
const props = defineProps({
item: {
type: Object as PropType<RouteRecordRaw>,
required: true
},
isCollapse: {
type: Boolean,
default: false
},
isFirstLevel: {
type: Boolean,
default: true
},
basePath: {
type: String,
default: ""
}
})
const alwaysShowRootMenu = computed(() => {
return props.item.meta && props.item.meta.alwaysShow
})
const showingChildNumber = computed(() => {
if (props.item.children) {
const showingChildren = props.item.children.filter((item) => {
return !(item.meta && item.meta.hidden)
})
return showingChildren.length
}
return 0
})
const theOnlyOneChild = computed(() => {
if (showingChildNumber.value > 1) {
return null
}
if (props.item.children) {
for (const child of props.item.children) {
if (!child.meta || !child.meta.hidden) {
return child
}
}
}
// If there is no children, return itself with path removed,
// because this.basePath already contains item's path information
return { ...props.item, path: "" }
})
const resolvePath = (routePath: string) => {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
return path.resolve(props.basePath, routePath)
}
</script>
<template>
<div v-if="!props.item.meta?.hidden" :class="{ 'simple-mode': props.isCollapse, 'first-level': props.isFirstLevel }">
<template v-if="!alwaysShowRootMenu && theOnlyOneChild && !theOnlyOneChild.children">
<SidebarItemLink v-if="theOnlyOneChild.meta" :to="resolvePath(theOnlyOneChild.path)">
<el-menu-item :index="resolvePath(theOnlyOneChild.path)">
<svg-icon v-if="theOnlyOneChild.meta.svgIcon" :name="theOnlyOneChild.meta.svgIcon" />
<component v-else-if="theOnlyOneChild.meta.elIcon" :is="theOnlyOneChild.meta.elIcon" class="el-icon" />
<template v-if="theOnlyOneChild.meta.title" #title>
{{ theOnlyOneChild.meta.title }}
</template>
</el-menu-item>
</SidebarItemLink>
</template>
<el-sub-menu v-else :index="resolvePath(props.item.path)" teleported>
<template #title>
<svg-icon v-if="props.item.meta && props.item.meta.svgIcon" :name="props.item.meta.svgIcon" />
<component v-else-if="props.item.meta && props.item.meta.elIcon" :is="props.item.meta.elIcon" class="el-icon" />
<span v-if="props.item.meta && props.item.meta.title">{{ props.item.meta.title }}</span>
</template>
<template v-if="props.item.children">
<sidebar-item
v-for="child in props.item.children"
:key="child.path"
:item="child"
:is-collapse="props.isCollapse"
:is-first-level="false"
:base-path="resolvePath(child.path)"
/>
</template>
</el-sub-menu>
</div>
</template>
<style lang="scss" scoped>
.svg-icon {
min-width: 1em;
margin-right: 12px;
font-size: 18px;
}
.el-icon {
width: 1em;
margin-right: 12px;
font-size: 18px;
}
.simple-mode {
&.first-level {
:deep(.el-sub-menu) {
.el-sub-menu__icon-arrow {
display: none;
}
span {
visibility: hidden;
}
}
}
}
</style>

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import { isExternal } from "@/utils/validate"
const props = defineProps({
to: {
type: String,
required: true
}
})
</script>
<template>
<a v-if="isExternal(props.to)" :href="props.to" target="_blank" rel="noopener">
<slot />
</a>
<router-link v-else :to="props.to">
<slot />
</router-link>
</template>

View File

@ -0,0 +1,52 @@
<script lang="ts" setup>
const props = defineProps({
collapse: {
type: Boolean,
default: true
}
})
</script>
<template>
<div class="sidebar-logo-container" :class="{ collapse: props.collapse }">
<transition name="sidebar-logo-fade">
<router-link v-if="props.collapse" key="collapse" to="/">
<img src="@/assets/layout/logo.png" class="sidebar-logo" />
</router-link>
<router-link v-else key="expand" to="/">
<img src="@/assets/layout/logo-text-1.png" class="sidebar-logo-text" />
</router-link>
</transition>
</div>
</template>
<style lang="scss" scoped>
.sidebar-logo-container {
position: relative;
width: 100%;
height: var(--v3-header-height);
line-height: var(--v3-header-height);
background-color: var(--v3-sidebarlogo-bg-color);
text-align: center;
overflow: hidden;
.sidebar-logo {
display: none;
}
.sidebar-logo-text {
height: 100%;
vertical-align: middle;
}
}
.collapse {
.sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
display: inline-block;
}
.sidebar-logo-text {
display: none;
}
}
</style>

View File

@ -0,0 +1,136 @@
<script lang="ts" setup>
import { computed } from "vue"
import { useRoute } from "vue-router"
import { storeToRefs } from "pinia"
import { useAppStore } from "@/store/modules/app"
import { usePermissionStore } from "@/store/modules/permission"
import { useSettingsStore } from "@/store/modules/settings"
import SidebarItem from "./SidebarItem.vue"
import SidebarLogo from "./SidebarLogo.vue"
import { getCssVariableValue } from "@/utils"
const v3SidebarMenuBgColor = getCssVariableValue("--v3-sidebar-menu-bg-color")
const v3SidebarMenuTextColor = getCssVariableValue("--v3-sidebar-menu-text-color")
const v3SidebarMenuActiveTextColor = getCssVariableValue("--v3-sidebar-menu-active-text-color")
const route = useRoute()
const appStore = useAppStore()
const permissionStore = usePermissionStore()
const settingsStore = useSettingsStore()
const { showSidebarLogo } = storeToRefs(settingsStore)
const activeMenu = computed(() => {
const { meta, path } = route
if (meta?.activeMenu) {
return meta.activeMenu
}
return path
})
const isCollapse = computed(() => {
return !appStore.sidebar.opened
})
</script>
<template>
<div :class="{ 'has-logo': showSidebarLogo }">
<SidebarLogo v-if="showSidebarLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="v3SidebarMenuBgColor"
:text-color="v3SidebarMenuTextColor"
:active-text-color="v3SidebarMenuActiveTextColor"
:unique-opened="true"
:collapse-transition="false"
mode="vertical"
>
<SidebarItem
v-for="route in permissionStore.routes"
:key="route.path"
:item="route"
:base-path="route.path"
:is-collapse="isCollapse"
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<style lang="scss" scoped>
@mixin tip-line {
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 100%;
background-color: var(--v3-sidebar-menu-tip-line-bg-color);
}
}
.has-logo {
.el-scrollbar {
height: calc(100% - var(--v3-header-height));
}
}
.el-scrollbar {
height: 100%;
:deep(.scrollbar-wrapper) {
//
overflow-x: hidden !important;
.el-scrollbar__view {
height: 100%;
}
}
//
:deep(.el-scrollbar__bar) {
&.is-horizontal {
//
display: none;
}
}
}
.el-menu {
border: none;
min-height: 100%;
width: 100% !important;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title),
:deep(.el-sub-menu .el-menu-item) {
height: var(--v3-sidebar-menu-item-height);
line-height: var(--v3-sidebar-menu-item-height);
&.is-active,
&:hover {
background-color: var(--v3-sidebar-menu-hover-bg-color);
}
display: block;
* {
vertical-align: middle;
}
}
:deep(.el-menu-item) {
&.is-active {
@include tip-line;
}
}
.el-menu--collapse {
:deep(.el-sub-menu) {
&.is-active {
.el-sub-menu__title {
color: var(--v3-sidebar-menu-active-text-color) !important;
@include tip-line;
}
}
}
}
</style>

View File

@ -0,0 +1,160 @@
<script lang="ts" setup>
import { type PropType, computed, ref, watch, nextTick } from "vue"
import { RouterLink, useRoute } from "vue-router"
import { ElScrollbar } from "element-plus"
import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue"
import { useSettingsStore } from "@/store/modules/settings"
import Screenfull from "@/components/Screenfull/index.vue"
const props = defineProps({
tagRefs: {
type: Object as PropType<InstanceType<typeof RouterLink>[]>,
required: true
}
})
const route = useRoute()
const settingsStore = useSettingsStore()
const scrollbarRef = ref<InstanceType<typeof ElScrollbar>>()
const scrollbarContentRef = ref<HTMLDivElement>()
/** 当前滚动条距离左边的距离 */
let currentScrollLeft = 0
/** 每次滚动距离 */
const translateDistance = 200
/** 滚动时触发 */
const scroll = ({ scrollLeft }: { scrollLeft: number }) => {
currentScrollLeft = scrollLeft
}
/** 鼠标滚轮滚动时触发 */
const wheelScroll = ({ deltaY }: WheelEvent) => {
if (/^-/.test(deltaY.toString())) {
scrollTo("left")
} else {
scrollTo("right")
}
}
/** 获取可能需要的宽度 */
const getWidth = () => {
/** 可滚动内容的长度 */
const scrollbarContentRefWidth = scrollbarContentRef.value!.clientWidth
/** 滚动可视区宽度 */
const scrollbarRefWidth = scrollbarRef.value!.wrapRef!.clientWidth
/** 最后剩余可滚动的宽度 */
const lastDistance = scrollbarContentRefWidth - scrollbarRefWidth - currentScrollLeft
return { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance }
}
/** 左右滚动 */
const scrollTo = (direction: "left" | "right", distance: number = translateDistance) => {
let scrollLeft = 0
const { scrollbarContentRefWidth, scrollbarRefWidth, lastDistance } = getWidth()
//
if (scrollbarRefWidth > scrollbarContentRefWidth) return
if (direction === "left") {
scrollLeft = Math.max(0, currentScrollLeft - distance)
} else {
scrollLeft = Math.min(currentScrollLeft + distance, currentScrollLeft + lastDistance)
}
scrollbarRef.value!.setScrollLeft(scrollLeft)
}
/** 移动到目标位置 */
const moveTo = () => {
const tagRefs = props.tagRefs
for (let i = 0; i < tagRefs.length; i++) {
// @ts-ignore
if (route.path === tagRefs[i].$props.to.path) {
// @ts-ignore
const el: HTMLElement = tagRefs[i].$el
const offsetWidth = el.offsetWidth
const offsetLeft = el.offsetLeft
const { scrollbarRefWidth } = getWidth()
// tag
if (offsetLeft < currentScrollLeft) {
const distance = currentScrollLeft - offsetLeft
scrollTo("left", distance)
return
}
// tag
const width = scrollbarRefWidth + currentScrollLeft - offsetWidth
if (offsetLeft > width) {
const distance = offsetLeft - width
scrollTo("right", distance)
return
}
}
}
}
watch(
route,
() => {
nextTick(moveTo)
},
{
deep: true
}
)
const showScreenfull = computed(() => {
return settingsStore.showScreenfull
})
</script>
<template>
<div class="scroll-container">
<el-icon class="arrow left" @click="scrollTo('left')">
<ArrowLeft />
</el-icon>
<el-scrollbar ref="scrollbarRef" @wheel.prevent="wheelScroll" @scroll="scroll">
<div ref="scrollbarContentRef" class="scrollbar-content">
<slot />
</div>
</el-scrollbar>
<el-icon class="arrow right" @click="scrollTo('right')">
<ArrowRight />
</el-icon>
<Screenfull v-if="showScreenfull" element=".app-main" openTips="内容区全屏" class="screenfull" />
</div>
</template>
<style lang="scss" scoped>
.scroll-container {
height: 100%;
user-select: none;
display: flex;
justify-content: space-between;
.arrow {
width: 40px;
height: 100%;
cursor: pointer;
&.left {
box-shadow: 5px 0 5px -6px #ccc;
}
&.right {
box-shadow: -5px 0 5px -6px #ccc;
}
}
.el-scrollbar {
flex: 1;
//
white-space: nowrap;
.scrollbar-content {
display: inline-block;
}
}
.screenfull {
width: 40px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
}
</style>

View File

@ -0,0 +1,268 @@
<script lang="ts" setup>
import { getCurrentInstance, onMounted, ref, watch } from "vue"
import { type RouteRecordRaw, RouterLink, useRoute, useRouter } from "vue-router"
import { type ITagView, useTagsViewStore } from "@/store/modules/tags-view"
import { usePermissionStore } from "@/store/modules/permission"
import ScrollPane from "./ScrollPane.vue"
import path from "path-browserify"
import { Close } from "@element-plus/icons-vue"
const instance = getCurrentInstance()
const router = useRouter()
const route = useRoute()
const tagsViewStore = useTagsViewStore()
const permissionStore = usePermissionStore()
const tagRefs = ref<InstanceType<typeof RouterLink>[]>([])
const visible = ref(false)
const top = ref(0)
const left = ref(0)
const selectedTag = ref<ITagView>({})
let affixTags: ITagView[] = []
const isActive = (tag: ITagView) => {
return tag.path === route.path
}
const isAffix = (tag: ITagView) => {
return tag.meta?.affix
}
const filterAffixTags = (routes: RouteRecordRaw[], basePath = "/") => {
let tags: ITagView[] = []
routes.forEach((route) => {
if (route.meta?.affix) {
const tagPath = path.resolve(basePath, route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta }
})
}
if (route.children) {
const childTags = filterAffixTags(route.children, route.path)
if (childTags.length >= 1) {
tags = tags.concat(childTags)
}
}
})
return tags
}
const initTags = () => {
affixTags = filterAffixTags(permissionStore.routes)
for (const tag of affixTags) {
// name
if (tag.name) {
tagsViewStore.addVisitedView(tag)
}
}
}
const addTags = () => {
if (route.name) {
tagsViewStore.addVisitedView(route)
tagsViewStore.addCachedView(route)
}
}
const refreshSelectedTag = (view: ITagView) => {
tagsViewStore.delCachedView(view)
router.replace({ path: "/redirect" + view.path, query: view.query })
}
const closeSelectedTag = (view: ITagView) => {
tagsViewStore.delVisitedView(view)
tagsViewStore.delCachedView(view)
if (isActive(view)) {
toLastView(tagsViewStore.visitedViews, view)
}
}
const closeOthersTags = () => {
if (selectedTag.value.fullPath !== route.path && selectedTag.value.fullPath !== undefined) {
router.push(selectedTag.value.fullPath)
}
tagsViewStore.delOthersVisitedViews(selectedTag.value)
tagsViewStore.delOthersCachedViews(selectedTag.value)
}
const closeAllTags = (view: ITagView) => {
tagsViewStore.delAllVisitedViews()
tagsViewStore.delAllCachedViews()
if (affixTags.some((tag) => tag.path === route.path)) {
return
}
toLastView(tagsViewStore.visitedViews, view)
}
const toLastView = (visitedViews: ITagView[], view: ITagView) => {
const latestView = visitedViews.slice(-1)[0]
if (latestView !== undefined && latestView.fullPath !== undefined) {
router.push(latestView.fullPath)
} else {
// TagsView
if (view.name === "Dashboard") {
//
router.push({ path: "/redirect" + view.path, query: view.query })
} else {
router.push("/")
}
}
}
const openMenu = (tag: ITagView, e: MouseEvent) => {
const menuMinWidth = 105
// container margin left
const offsetLeft = instance!.proxy!.$el.getBoundingClientRect().left
// container width
const offsetWidth = instance!.proxy!.$el.offsetWidth
// left boundary
const maxLeft = offsetWidth - menuMinWidth
// 15: margin right
const left15 = e.clientX - offsetLeft + 15
if (left15 > maxLeft) {
left.value = maxLeft
} else {
left.value = left15
}
top.value = e.clientY
visible.value = true
selectedTag.value = tag
}
const closeMenu = () => {
visible.value = false
}
watch(
route,
() => {
addTags()
},
{
deep: true
}
)
watch(visible, (value) => {
if (value) {
document.body.addEventListener("click", closeMenu)
} else {
document.body.removeEventListener("click", closeMenu)
}
})
onMounted(() => {
initTags()
addTags()
})
</script>
<template>
<div class="tags-view-container">
<ScrollPane class="tags-view-wrapper" :tagRefs="tagRefs">
<router-link
ref="tagRefs"
v-for="tag in tagsViewStore.visitedViews"
:key="tag.path"
:class="isActive(tag) ? 'active' : ''"
:to="{ path: tag.path, query: tag.query }"
class="tags-view-item"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent="openMenu(tag, $event)"
>
{{ tag.meta?.title }}
<el-icon v-if="!isAffix(tag)" :size="12" @click.prevent.stop="closeSelectedTag(tag)">
<Close />
</el-icon>
</router-link>
</ScrollPane>
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)">刷新</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">关闭</li>
<li @click="closeOthersTags">关闭其它</li>
<li @click="closeAllTags(selectedTag)">关闭所有</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.tags-view-container {
height: var(--v3-tagsview-height);
width: 100%;
background-color: #fff;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 #00000010, 0 0 3px 0 #00000010;
.tags-view-wrapper {
.tags-view-item {
display: inline-block;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid var(--v3-tagsview-tag-border-color);
border-radius: var(--v3-tagsview-tag-border-radius);
color: var(--v3-tagsview-tag-text-color);
background-color: var(--v3-tagsview-tag-bg-color);
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
&:first-of-type {
margin-left: 5px;
}
&:last-of-type {
margin-right: 5px;
}
&.active {
background-color: var(--v3-tagsview-tag-active-bg-color);
color: var(--v3-tagsview-tag-active-text-color);
border-color: var(--v3-tagsview-tag-active-border-color);
&::before {
content: "";
background-color: var(--v3-tagsview-tag-active-before-color);
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 2px;
}
}
.el-icon {
margin: 0 2px;
vertical-align: middle;
border-radius: 50%;
&:hover {
background-color: var(--v3-tagsview-tag-icon-hover-bg-color);
color: var(--v3-tagsview-tag-icon-hover-color);
}
}
}
}
.contextmenu {
margin: 0;
background-color: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 #00000030;
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background-color: #eee;
}
}
}
}
</style>

View File

@ -0,0 +1,6 @@
export { default as AppMain } from "./AppMain.vue"
export { default as NavigationBar } from "./NavigationBar/index.vue"
export { default as Settings } from "./Settings/index.vue"
export { default as Sidebar } from "./Sidebar/index.vue"
export { default as TagsView } from "./TagsView/index.vue"
export { default as RightPanel } from "./RightPanel/index.vue"

View File

@ -0,0 +1,51 @@
import { watch, onBeforeMount, onMounted, onBeforeUnmount } from "vue"
import { useRoute } from "vue-router"
import { useAppStore, DeviceType } from "@/store/modules/app"
/** 参考 Bootstrap 的响应式设计 WIDTH = 992 */
const WIDTH = 992
/** 根据大小变化重新布局 */
export default () => {
const route = useRoute()
const appStore = useAppStore()
const _isMobile = () => {
const rect = document.body.getBoundingClientRect()
return rect.width - 1 < WIDTH
}
const _resizeHandler = () => {
if (!document.hidden) {
const isMobile = _isMobile()
appStore.toggleDevice(isMobile ? DeviceType.Mobile : DeviceType.Desktop)
if (isMobile) {
appStore.closeSidebar(true)
}
}
}
watch(
() => route.name,
() => {
if (appStore.device === DeviceType.Mobile && appStore.sidebar.opened) {
appStore.closeSidebar(false)
}
}
)
onBeforeMount(() => {
window.addEventListener("resize", _resizeHandler)
})
onMounted(() => {
if (_isMobile()) {
appStore.toggleDevice(DeviceType.Mobile)
appStore.closeSidebar(true)
}
})
onBeforeUnmount(() => {
window.removeEventListener("resize", _resizeHandler)
})
}

162
src/layout/index.vue Normal file
View File

@ -0,0 +1,162 @@
<script lang="ts" setup>
import { computed } from "vue"
import { useAppStore, DeviceType } from "@/store/modules/app"
import { useSettingsStore } from "@/store/modules/settings"
import { AppMain, NavigationBar, Settings, Sidebar, TagsView, RightPanel } from "./components"
import useResize from "./hooks/useResize"
const appStore = useAppStore()
const settingsStore = useSettingsStore()
/** Layout 布局响应式 */
useResize()
const classObj = computed(() => {
return {
hideSidebar: !appStore.sidebar.opened,
openSidebar: appStore.sidebar.opened,
withoutAnimation: appStore.sidebar.withoutAnimation,
mobile: appStore.device === DeviceType.Mobile,
showGreyMode: showGreyMode.value,
showColorWeakness: showColorWeakness.value
}
})
const showSettings = computed(() => {
return settingsStore.showSettings
})
const showTagsView = computed(() => {
return settingsStore.showTagsView
})
const fixedHeader = computed(() => {
return settingsStore.fixedHeader
})
const showGreyMode = computed(() => {
return settingsStore.showGreyMode
})
const showColorWeakness = computed(() => {
return settingsStore.showColorWeakness
})
const handleClickOutside = () => {
appStore.closeSidebar(false)
}
</script>
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="classObj.mobile && classObj.openSidebar" class="drawer-bg" @click="handleClickOutside" />
<Sidebar class="sidebar-container" />
<div :class="{ hasTagsView: showTagsView }" class="main-container">
<div :class="{ 'fixed-header': fixedHeader }">
<NavigationBar />
<TagsView v-show="showTagsView" />
</div>
<AppMain />
<RightPanel v-if="showSettings">
<Settings />
</RightPanel>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "@/styles/mixins.scss";
.app-wrapper {
@include clearfix;
position: relative;
width: 100%;
}
.showGreyMode {
filter: grayscale(1);
}
.showColorWeakness {
filter: invert(0.8);
}
.drawer-bg {
background-color: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.main-container {
min-height: 100%;
transition: margin-left 0.28s;
margin-left: var(--v3-sidebar-width);
position: relative;
}
.sidebar-container {
transition: width 0.28s;
width: var(--v3-sidebar-width) !important;
height: 100%;
position: fixed;
font-size: 0px;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - var(--v3-sidebar-width));
transition: width 0.28s;
}
.hideSidebar {
.main-container {
margin-left: var(--v3-sidebar-hide-width);
}
.sidebar-container {
width: var(--v3-sidebar-hide-width) !important;
}
.fixed-header {
width: calc(100% - var(--v3-sidebar-hide-width));
}
}
// for mobile response
.mobile {
.main-container {
margin-left: 0px;
}
.sidebar-container {
transition: transform 0.28s;
width: var(--v3-sidebar-width) !important;
}
&.openSidebar {
position: fixed;
top: 0;
}
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(calc(0px - var(--v3-sidebar-width)), 0, 0);
}
}
.fixed-header {
width: 100%;
}
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
</style>

29
src/main.ts Normal file
View File

@ -0,0 +1,29 @@
// core
import { createApp } from "vue"
import App from "@/App.vue"
import store from "@/store"
import router from "@/router"
import "@/router/permission"
// load
import { loadSvg } from "@/icons"
import { loadPlugins } from "@/plugins"
import { loadDirectives } from "@/directives"
// css
import "uno.css"
import "normalize.css"
import "element-plus/dist/index.css"
import "element-plus/theme-chalk/dark/css-vars.css"
import "vxe-table/lib/style.css"
import "vxe-table-plugin-element/dist/style.css"
import "@/styles/index.scss"
const app = createApp(App)
/** 加载插件 */
loadPlugins(app)
/** 加载全局 SVG */
loadSvg(app)
/** 加载自定义指令 */
loadDirectives(app)
app.use(store).use(router).mount("#app")

View File

@ -0,0 +1,9 @@
import { type App } from "vue"
import * as ElementPlusIconsVue from "@element-plus/icons-vue"
export function loadElementPlusIcon(app: App) {
/** 注册所有 Element Plus Icon */
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
}

View File

@ -0,0 +1,7 @@
import { type App } from "vue"
import ElementPlus from "element-plus"
export function loadElementPlus(app: App) {
/** Element Plus 组件完整引入 */
app.use(ElementPlus)
}

10
src/plugins/index.ts Normal file
View File

@ -0,0 +1,10 @@
import { type App } from "vue"
import { loadElementPlus } from "./element-plus"
import { loadElementPlusIcon } from "./element-plus-icon"
import { loadVxeTable } from "./vxe-table"
export function loadPlugins(app: App) {
loadElementPlus(app)
loadElementPlusIcon(app)
loadVxeTable(app)
}

View File

@ -0,0 +1,66 @@
import { type App } from "vue"
// https://vxetable.cn/#/table/start/install
import VXETable from "vxe-table"
// https://github.com/x-extends/vxe-table-plugin-element
import VXETablePluginElement from "vxe-table-plugin-element"
VXETable.use(VXETablePluginElement)
/** 全局默认参数 */
VXETable.setup({
/** 全局尺寸 */
size: "medium",
/** 全局 zIndex 起始值,如果项目的的 z-index 样式值过大时就需要跟随设置更大,避免被遮挡 */
zIndex: 9999,
/** 版本号,对于某些带数据缓存的功能有用到,上升版本号可以用于重置数据 */
version: 0,
/** 全局 loading 提示内容,如果为 null 则不显示文本 */
loadingText: null,
table: {
showHeader: true,
showOverflow: "tooltip",
showHeaderOverflow: "tooltip",
autoResize: true,
// stripe: false,
border: "inner",
// round: false,
emptyText: "暂无数据",
rowConfig: {
isHover: true,
isCurrent: true
},
columnConfig: {
resizable: false
},
align: "center",
headerAlign: "center",
/** 行数据的唯一主键字段名 */
rowId: "_VXE_ID"
},
pager: {
// size: "medium",
/** 配套的样式 */
perfect: false,
pageSize: 10,
pagerCount: 7,
pageSizes: [10, 20, 50],
layouts: ["Total", "PrevJump", "PrevPage", "Number", "NextPage", "NextJump", "Sizes", "FullJump"]
},
modal: {
minWidth: 500,
minHeight: 400,
lockView: true,
mask: true,
// duration: 3000,
// marginSize: 20,
dblclickZoom: false,
showTitleOverflow: true,
transfer: true,
draggable: false
}
})
export function loadVxeTable(app: App) {
/** Vxe Table 组件完整引入 */
app.use(VXETable)
}

296
src/router/index.ts Normal file
View File

@ -0,0 +1,296 @@
import { type RouteRecordRaw, createRouter, createWebHashHistory, createWebHistory } from "vue-router"
const Layout = () => import("@/layout/index.vue")
/** 常驻路由 */
export const constantRoutes: RouteRecordRaw[] = [
{
path: "/redirect",
component: Layout,
meta: {
hidden: true
},
children: [
{
path: "/redirect/:path(.*)",
component: () => import("@/views/redirect/index.vue")
}
]
},
{
path: "/403",
component: () => import("@/views/error-page/403.vue"),
meta: {
hidden: true
}
},
{
path: "/404",
component: () => import("@/views/error-page/404.vue"),
meta: {
hidden: true
},
alias: "/:pathMatch(.*)*"
},
{
path: "/login",
component: () => import("@/views/login/index.vue"),
meta: {
hidden: true
}
},
{
path: "/",
component: Layout,
redirect: "/dashboard",
children: [
{
path: "dashboard",
component: () => import("@/views/dashboard/index.vue"),
name: "Dashboard",
meta: {
title: "首页",
svgIcon: "dashboard",
affix: true
}
}
]
},
{
path: "/unocss",
component: Layout,
redirect: "/unocss/index",
children: [
{
path: "index",
component: () => import("@/views/unocss/index.vue"),
name: "UnoCSS",
meta: {
title: "unocss",
svgIcon: "unocss"
}
}
]
},
{
path: "/link",
component: Layout,
children: [
{
path: "https://juejin.cn/post/7089377403717287972",
component: () => {},
name: "Link",
meta: {
title: "外链",
svgIcon: "link"
}
}
]
},
{
path: "/table",
component: Layout,
redirect: "/table/element-plus",
name: "Table",
meta: {
title: "表格",
elIcon: "Grid"
},
children: [
{
path: "element-plus",
component: () => import("@/views/table/element-plus/index.vue"),
name: "ElementPlus",
meta: {
title: "Element Plus",
keepAlive: true
}
},
{
path: "vxe-table",
component: () => import("@/views/table/vxe-table/index.vue"),
name: "VxeTable",
meta: {
title: "Vxe Table",
keepAlive: true
}
}
]
},
{
path: "/menu",
component: Layout,
redirect: "/menu/menu1",
name: "Menu",
meta: {
title: "多级菜单",
svgIcon: "menu"
},
children: [
{
path: "menu1",
component: () => import("@/views/menu/menu1/index.vue"),
redirect: "/menu/menu1/menu1-1",
name: "Menu1",
meta: {
title: "menu1"
},
children: [
{
path: "menu1-1",
component: () => import("@/views/menu/menu1/menu1-1/index.vue"),
name: "Menu1-1",
meta: {
title: "menu1-1"
}
},
{
path: "menu1-2",
component: () => import("@/views/menu/menu1/menu1-2/index.vue"),
redirect: "/menu/menu1/menu1-2/menu1-2-1",
name: "Menu1-2",
meta: {
title: "menu1-2"
},
children: [
{
path: "menu1-2-1",
component: () => import("@/views/menu/menu1/menu1-2/menu1-2-1/index.vue"),
name: "Menu1-2-1",
meta: {
title: "menu1-2-1"
}
},
{
path: "menu1-2-2",
component: () => import("@/views/menu/menu1/menu1-2/menu1-2-2/index.vue"),
name: "Menu1-2-2",
meta: {
title: "menu1-2-2"
}
}
]
},
{
path: "menu1-3",
component: () => import("@/views/menu/menu1/menu1-3/index.vue"),
name: "Menu1-3",
meta: {
title: "menu1-3"
}
}
]
},
{
path: "menu2",
component: () => import("@/views/menu/menu2/index.vue"),
name: "Menu2",
meta: {
title: "menu2"
}
}
]
},
{
path: "/hook-demo",
component: Layout,
redirect: "/hook-demo/use-fetch-select",
name: "HookDemo",
meta: {
title: "hook 示例",
elIcon: "Menu",
alwaysShow: true
},
children: [
{
path: "use-fetch-select",
component: () => import("@/views/hook-demo/use-fetch-select.vue"),
name: "UseFetchSelect",
meta: {
title: "useFetchSelect"
}
},
{
path: "use-fullscreen-loading",
component: () => import("@/views/hook-demo/use-fullscreen-loading.vue"),
name: "UseFullscreenLoading",
meta: {
title: "useFullscreenLoading"
}
}
]
}
]
/**
*
* (Roles )
* Name
*/
export const asyncRoutes: RouteRecordRaw[] = [
{
path: "/permission",
component: Layout,
redirect: "/permission/page",
name: "Permission",
meta: {
title: "权限管理",
svgIcon: "lock",
roles: ["admin", "editor"], // 可以在根路由中设置角色
alwaysShow: true // 将始终显示根菜单
},
children: [
{
path: "page",
component: () => import("@/views/permission/page.vue"),
name: "PagePermission",
meta: {
title: "页面权限",
roles: ["admin"] // 或者在子导航中设置角色
}
},
{
path: "directive",
component: () => import("@/views/permission/directive.vue"),
name: "DirectivePermission",
meta: {
title: "指令权限" // 如果未设置角色,则表示:该页面不需要权限,但会继承根路由的角色
}
}
]
},
{
path: "/:pathMatch(.*)*", // Must put the 'ErrorPage' route at the end, 必须将 'ErrorPage' 路由放在最后
redirect: "/404",
name: "ErrorPage",
meta: {
hidden: true
}
}
]
const router = createRouter({
history:
import.meta.env.VITE_ROUTER_HISTORY === "hash"
? createWebHashHistory(import.meta.env.VITE_PUBLIC_PATH)
: createWebHistory(import.meta.env.VITE_PUBLIC_PATH),
routes: constantRoutes
})
/** 重置路由 */
export function resetRouter() {
// 注意:所有动态路由路由必须带有 Name 属性,否则可能会不能完全重置干净
try {
router.getRoutes().forEach((route) => {
const { name, meta } = route
if (name && meta.roles?.length) {
router.hasRoute(name) && router.removeRoute(name)
}
})
} catch (error) {
// 强制刷新浏览器也行,只是交互体验不是很好
window.location.reload()
}
}
export default router

71
src/router/permission.ts Normal file
View File

@ -0,0 +1,71 @@
import router from "@/router"
import { useUserStoreHook } from "@/store/modules/user"
import { usePermissionStoreHook } from "@/store/modules/permission"
import { ElMessage } from "element-plus"
import { whiteList } from "@/config/white-list"
import { getToken } from "@/utils/cache/cookies"
import asyncRouteSettings from "@/config/async-route"
import NProgress from "nprogress"
import "nprogress/nprogress.css"
NProgress.configure({ showSpinner: false })
router.beforeEach(async (to, _from, next) => {
NProgress.start()
const userStore = useUserStoreHook()
const permissionStore = usePermissionStoreHook()
// 判断该用户是否登录
if (getToken()) {
if (to.path === "/login") {
// 如果已经登录,并准备进入 Login 页面,则重定向到主页
next({ path: "/" })
NProgress.done()
} else {
// 检查用户是否已获得其权限角色
if (userStore.roles.length === 0) {
try {
if (asyncRouteSettings.open) {
// 注意:角色必须是一个数组! 例如: ['admin'] 或 ['developer', 'editor']
await userStore.getInfo()
const roles = userStore.roles
// 根据角色生成可访问的 Routes可访问路由 = 常驻路由 + 有访问权限的动态路由)
permissionStore.setRoutes(roles)
} else {
// 没有开启动态路由功能,则启用默认角色
userStore.setRoles(asyncRouteSettings.defaultRoles)
permissionStore.setRoutes(asyncRouteSettings.defaultRoles)
}
// 将'有访问权限的动态路由' 添加到 Router 中
permissionStore.dynamicRoutes.forEach((route) => {
router.addRoute(route)
})
// 确保添加路由已完成
// 设置 replace: true, 因此导航将不会留下历史记录
next({ ...to, replace: true })
} catch (err: any) {
// 过程中发生任何错误,都直接重置 Token并重定向到登录页面
userStore.resetToken()
ElMessage.error(err.message || "路由守卫过程发生错误")
next("/login")
NProgress.done()
}
} else {
next()
}
}
} else {
// 如果没有 Token
if (whiteList.indexOf(to.path) !== -1) {
// 如果在免登录的白名单中,则直接进入
next()
} else {
// 其他没有访问权限的页面将被重定向到登录页面
next("/login")
NProgress.done()
}
}
})
router.afterEach(() => {
NProgress.done()
})

5
src/store/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { createPinia } from "pinia"
const store = createPinia()
export default store

41
src/store/modules/app.ts Normal file
View File

@ -0,0 +1,41 @@
import { reactive, ref } from "vue"
import { defineStore } from "pinia"
import { getSidebarStatus, setSidebarStatus } from "@/utils/cache/localStorage"
export enum DeviceType {
Mobile,
Desktop
}
interface ISidebar {
opened: boolean
withoutAnimation: boolean
}
export const useAppStore = defineStore("app", () => {
const sidebar: ISidebar = reactive({
opened: getSidebarStatus() !== "closed",
withoutAnimation: false
})
const device = ref<DeviceType>(DeviceType.Desktop)
const toggleSidebar = (withoutAnimation: boolean) => {
sidebar.opened = !sidebar.opened
sidebar.withoutAnimation = withoutAnimation
if (sidebar.opened) {
setSidebarStatus("opened")
} else {
setSidebarStatus("closed")
}
}
const closeSidebar = (withoutAnimation: boolean) => {
sidebar.opened = false
sidebar.withoutAnimation = withoutAnimation
setSidebarStatus("closed")
}
const toggleDevice = (value: DeviceType) => {
device.value = value
}
return { device, sidebar, toggleSidebar, closeSidebar, toggleDevice }
})

View File

@ -0,0 +1,57 @@
import { ref } from "vue"
import store from "@/store"
import { defineStore } from "pinia"
import { type RouteRecordRaw } from "vue-router"
import { constantRoutes, asyncRoutes } from "@/router"
import asyncRouteSettings from "@/config/async-route"
const hasPermission = (roles: string[], route: RouteRecordRaw) => {
if (route.meta && route.meta.roles) {
return roles.some((role) => {
if (route.meta?.roles !== undefined) {
return route.meta.roles.includes(role)
} else {
return false
}
})
} else {
return true
}
}
const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
const res: RouteRecordRaw[] = []
routes.forEach((route) => {
const r = { ...route }
if (hasPermission(roles, r)) {
if (r.children) {
r.children = filterAsyncRoutes(r.children, roles)
}
res.push(r)
}
})
return res
}
export const usePermissionStore = defineStore("permission", () => {
const routes = ref<RouteRecordRaw[]>([])
const dynamicRoutes = ref<RouteRecordRaw[]>([])
const setRoutes = (roles: string[]) => {
let accessedRoutes
if (!asyncRouteSettings.open) {
accessedRoutes = asyncRoutes
} else {
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
}
routes.value = constantRoutes.concat(accessedRoutes)
dynamicRoutes.value = accessedRoutes
}
return { routes, dynamicRoutes, setRoutes }
})
/** 在 setup 外使用 */
export function usePermissionStoreHook() {
return usePermissionStore(store)
}

View File

@ -0,0 +1,27 @@
import { ref } from "vue"
import { defineStore } from "pinia"
import layoutSettings from "@/config/layout"
export const useSettingsStore = defineStore("settings", () => {
const fixedHeader = ref<boolean>(layoutSettings.fixedHeader)
const showSettings = ref<boolean>(layoutSettings.showSettings)
const showTagsView = ref<boolean>(layoutSettings.showTagsView)
const showSidebarLogo = ref<boolean>(layoutSettings.showSidebarLogo)
const showNotify = ref<boolean>(layoutSettings.showNotify)
const showThemeSwitch = ref<boolean>(layoutSettings.showThemeSwitch)
const showScreenfull = ref<boolean>(layoutSettings.showScreenfull)
const showGreyMode = ref<boolean>(layoutSettings.showGreyMode)
const showColorWeakness = ref<boolean>(layoutSettings.showColorWeakness)
return {
fixedHeader,
showSettings,
showTagsView,
showSidebarLogo,
showNotify,
showThemeSwitch,
showScreenfull,
showGreyMode,
showColorWeakness
}
})

View File

@ -0,0 +1,94 @@
import { ref } from "vue"
import { defineStore } from "pinia"
import { type RouteLocationNormalized } from "vue-router"
export type ITagView = Partial<RouteLocationNormalized>
export const useTagsViewStore = defineStore("tags-view", () => {
const visitedViews = ref<ITagView[]>([])
const cachedViews = ref<string[]>([])
//#region add
const addVisitedView = (view: ITagView) => {
if (
visitedViews.value.some((v, index) => {
if (v.path === view.path) {
if (v.fullPath !== view.fullPath) {
// 防止 query 参数丢失
visitedViews.value[index] = Object.assign({}, view)
}
return true
}
})
) {
return
}
visitedViews.value.push(Object.assign({}, view))
}
const addCachedView = (view: ITagView) => {
if (typeof view.name !== "string") return
if (cachedViews.value.includes(view.name)) return
if (view.meta?.keepAlive) {
cachedViews.value.push(view.name)
}
}
//#endregion
//#region del
const delVisitedView = (view: ITagView) => {
for (const [i, v] of visitedViews.value.entries()) {
if (v.path === view.path) {
visitedViews.value.splice(i, 1)
break
}
}
}
const delCachedView = (view: ITagView) => {
if (typeof view.name !== "string") return
const index = cachedViews.value.indexOf(view.name)
index > -1 && cachedViews.value.splice(index, 1)
}
//#endregion
//#region delOthers
const delOthersVisitedViews = (view: ITagView) => {
visitedViews.value = visitedViews.value.filter((v) => {
return v.meta?.affix || v.path === view.path
})
}
const delOthersCachedViews = (view: ITagView) => {
if (typeof view.name !== "string") return
const index = cachedViews.value.indexOf(view.name)
if (index > -1) {
cachedViews.value = cachedViews.value.slice(index, index + 1)
} else {
// 如果 index = -1, 没有缓存的 tags
cachedViews.value = []
}
}
//#endregion
//#region delAll
const delAllVisitedViews = () => {
// keep affix tags
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
visitedViews.value = affixTags
}
const delAllCachedViews = () => {
cachedViews.value = []
}
//#endregion
return {
visitedViews,
cachedViews,
addVisitedView,
addCachedView,
delVisitedView,
delCachedView,
delOthersVisitedViews,
delOthersCachedViews,
delAllVisitedViews,
delAllCachedViews
}
})

103
src/store/modules/user.ts Normal file
View File

@ -0,0 +1,103 @@
import { ref } from "vue"
import store from "@/store"
import { defineStore } from "pinia"
import { usePermissionStore } from "./permission"
import { useTagsViewStore } from "./tags-view"
import { getToken, removeToken, setToken } from "@/utils/cache/cookies"
import router, { resetRouter } from "@/router"
import { loginApi, getUserInfoApi } from "@/api/login"
import { type ILoginRequestData } from "@/api/login/types/login"
import { type RouteRecordRaw } from "vue-router"
import asyncRouteSettings from "@/config/async-route"
export const useUserStore = defineStore("user", () => {
const token = ref<string>(getToken() || "")
const roles = ref<string[]>([])
const username = ref<string>("")
const permissionStore = usePermissionStore()
const tagsViewStore = useTagsViewStore()
/** 设置角色数组 */
const setRoles = (value: string[]) => {
roles.value = value
}
/** 登录 */
const login = (loginData: ILoginRequestData) => {
return new Promise((resolve, reject) => {
loginApi({
username: loginData.username,
password: loginData.password,
code: loginData.code
})
.then((res) => {
setToken(res.data.token)
token.value = res.data.token
resolve(true)
})
.catch((error) => {
reject(error)
})
})
}
/** 获取用户详情 */
const getInfo = () => {
return new Promise((resolve, reject) => {
getUserInfoApi()
.then((res) => {
const data = res.data
username.value = data.username
// 验证返回的 roles 是否是一个非空数组
if (data.roles && data.roles.length > 0) {
roles.value = data.roles
} else {
// 塞入一个没有任何作用的默认角色,不然路由守卫逻辑会无限循环
roles.value = asyncRouteSettings.defaultRoles
}
resolve(res)
})
.catch((error) => {
reject(error)
})
})
}
/** 切换角色 */
const changeRoles = async (role: string) => {
const newToken = "token-" + role
token.value = newToken
setToken(newToken)
await getInfo()
permissionStore.setRoutes(roles.value)
resetRouter()
permissionStore.dynamicRoutes.forEach((item: RouteRecordRaw) => {
router.addRoute(item)
})
_resetTagsView()
}
/** 登出 */
const logout = () => {
removeToken()
token.value = ""
roles.value = []
resetRouter()
_resetTagsView()
}
/** 重置 Token */
const resetToken = () => {
removeToken()
token.value = ""
roles.value = []
}
/** 重置 visited views 和 cached views */
const _resetTagsView = () => {
tagsViewStore.delAllVisitedViews()
tagsViewStore.delAllCachedViews()
}
return { token, roles, username, setRoles, login, getInfo, changeRoles, logout, resetToken }
})
/** 在 setup 外使用 */
export function useUserStoreHook() {
return useUserStore(store)
}

View File

@ -0,0 +1,23 @@
/** 自定义 Element Plus 样式 */
// 表格
.el-table {
// 表头
th.el-table__cell {
background-color: var(--el-fill-color-light) !important;
}
}
// 分页
.el-pagination {
// 参考 Bootstrap 的响应式设计 WIDTH = 768
@media screen and (max-width: 768px) {
.el-pagination__total,
.el-pagination__sizes,
.el-pagination__jump,
.btn-prev,
.btn-next {
display: none !important;
}
}
}

50
src/styles/index.scss Normal file
View File

@ -0,0 +1,50 @@
// 全局 CSS 变量
@import "./variables.css";
// Transition
@import "./transition.scss";
// Element Plus
@import "./element-plus.scss";
// Vxe Table
@import "./vxe-table.scss";
// 注册多主题
@import "./theme/register.scss";
// 业务页面几乎都应该在根元素上挂载 class="app-container"以保持页面美观
.app-container {
padding: 20px;
}
html {
height: 100%;
}
body {
height: 100%;
background-color: var(--v3-body-bg-color);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial,
sans-serif;
}
#app {
height: 100%;
}
*,
*:before,
*:after {
box-sizing: border-box;
}
a,
a:focus,
a:hover {
color: inherit;
outline: none;
text-decoration: none;
}
div:focus {
outline: none;
}

7
src/styles/mixins.scss Normal file
View File

@ -0,0 +1,7 @@
@mixin clearfix {
&:after {
content: "";
display: table;
clear: both;
}
}

View File

@ -0,0 +1,20 @@
/** Element Plus 相关 */
// 侧边栏的 item popper
.el-popper {
border: none !important;
.el-menu {
background-color: lighten($theme-bg-color, 4%) !important;
.el-menu-item {
background-color: lighten($theme-bg-color, 4%) !important;
&.is-active,
&:hover {
background-color: lighten($theme-bg-color, 8%) !important;
color: $active-font-color !important;
}
}
.el-sub-menu__title {
background-color: lighten($theme-bg-color, 4%) !important;
}
}
}

View File

@ -0,0 +1,5 @@
/** ErrorPage 页面相关 */
.error-page {
background-color: $theme-bg-color;
}

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