🎨添加静态打包
Some checks failed
continuous-integration/drone/tag Build is failing

This commit is contained in:
coward 2024-08-21 09:10:01 +08:00
parent 941b8da804
commit 12e551b4e9
135 changed files with 15749 additions and 3 deletions

6
.gitignore vendored
View File

@ -261,6 +261,12 @@ web/*.njsproj
web/*.sln
web/tsconfig.tsbuildinfo
dist/assets
dist/resource
dist/favicon.png
dist/favicon.svg
dist/index.html
template/tmp/*
logs/*
app.yaml

View File

@ -1,9 +1,23 @@
# 打包前端
FROM node:18-alpine as build-front
WORKDIR front
COPY . .
WORKDIR web
RUN corepack enable
RUN corepack prepare pnpm@8.6.10 --activate
RUN npm config set registry https://registry.npmmirror.com
RUN pnpm install
RUN pnpm build
# 前后端集成打包
FROM golang:alpine as build
FROM golang:alpine as build-backend
RUN apk add upx
WORKDIR /build
COPY . .
COPY --from=build-front /front/dist/ /build/dist
# sqlite必须
ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
@ -23,8 +37,8 @@ RUN apk --no-cache add ca-certificates wireguard-tools jq iptables
WORKDIR /app
RUN mkdir -p db
COPY --from=build --chown=wgui:wgui /build/wgui /app
COPY --from=build /build/template/* /app/template/
COPY --from=build-backend --chown=wgui:wgui /build/wgui /app
COPY --from=build-backend /build/template/* /app/template/
RUN chmod +x wgui

6
dist/static.go vendored Normal file
View File

@ -0,0 +1,6 @@
package dist
import "embed"
//go:embed index.html favicon.png favicon.svg assets resource
var Static embed.FS

View File

@ -2,7 +2,9 @@ package router
import (
"github.com/gin-gonic/gin"
"net/http"
"wireguard-ui/config"
"wireguard-ui/dist"
)
type Option func(engine *gin.RouterGroup)
@ -24,6 +26,8 @@ func InitRouter() *gin.Engine {
r.Static("/assets", config.Config.File.Path)
}
r.StaticFS("/web", http.FS(dist.Static))
for _, opt := range options {
opt(r.Group("api"))
}

45
web/.cz-config.js Normal file
View File

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

19
web/.drone.yml Normal file
View File

@ -0,0 +1,19 @@
kind: pipeline
type: docker
name: wireguard-ui
trigger:
event: [tag]
steps:
- name: builder
image: plugins/docker
settings:
registry: gitea.mrx.ltd # 镜像仓库地址
repo: gitea.mrx.ltd/go-pkg/wireguard-ui # 镜像仓库地址
username:
from_secret: docker_user
password:
from_secret: docker_pwd
use_cache: true
auto_tag: true

5
web/.editorconfig Normal file
View File

@ -0,0 +1,5 @@
root = true
[*]
charset = utf-8
end_of_line = lf

13
web/.env.development Normal file
View File

@ -0,0 +1,13 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH = '/'
VITE_USE_HASH = true
# 是否启用MOCK
VITE_USE_MOCK = true
# 是否启用MOCK
VITE_USE_PROXY = true
# base api
VITE_BASE_API = '/api'

13
web/.env.github Normal file
View File

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

16
web/.env.production Normal file
View File

@ -0,0 +1,16 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH = '/web'
VITE_USE_HASH = true
# 是否启用MOCK
VITE_USE_MOCK = false
# base api
VITE_BASE_API = '/api'
# 是否启用压缩
VITE_USE_COMPRESS = false
# 压缩类型
VITE_COMPRESS_TYPE = gzip

View File

@ -0,0 +1,62 @@
{
"globals": {
"$loadingBar": true,
"$message": true,
"defineOptions": true,
"$dialog": true,
"$notification": true,
"EffectScope": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}

4
web/.eslintignore Normal file
View File

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

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

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

4
web/.husky/commit-msg Normal file
View File

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

4
web/.husky/pre-commit Normal file
View File

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

3
web/.prettierignore Normal file
View File

@ -0,0 +1,3 @@
/node_modules/**
/dist/*
/public/*

7
web/.prettierrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"printWidth": 100,
"singleQuote": true,
"semi": false,
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "ignore"
}

22
web/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# 打包前端
FROM node:18-alpine as build-stage
WORKDIR front
COPY . .
WORKDIR web
RUN corepack enable
RUN corepack prepare pnpm@8.6.10 --activate
RUN npm config set registry https://registry.npmmirror.com
RUN pnpm install
RUN pnpm run build
# 前端集成打包
FROM nginx:alpine
WORKDIR /data
# 替换掉Nginx默认配置
RUN rm /etc/nginx/conf.d/default.conf
COPY --from=build-stage /front/docker/web.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /front/dist/ ./
# 复制编译后的文件到容器内
RUN /bin/sh -c 'echo init ok'

21
web/LICENSE Normal file
View File

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

208
web/README.EN.md Normal file
View File

@ -0,0 +1,208 @@
<p align="center">
<a href="https://github.com/zclzone/vue-naive-admin">
<img alt="Vue Naive Admin Logo" width="200" src="./src/assets/images/logo.png">
</a>
</p>
<p align="center">
<a href="./LICENSE"><img allt="MIT License" src="https://badgen.net/github/license/zclzone/vue-naive-admin"/></a>
</p>
<p align='center'>
<b>英文</b> |
<a href="https://github.com/zclzone/vue-naive-admin/blob/main/README.md">中文</a>
</p>
### Introduction
[Vue Naive Admin](https://github.com/zclzone/vue-naive-admin) is a **completely open source free and commercially allowed ** admin templateBased on the latest technology stack of front-end such as `Vue3、Vite3、Pinia、Unocss and Naive UI`. Compared with other more popular backend management templates, this project is more concise, lightweight, fresh style, very low learning costs, ideal for small and medium-sized projects or personal projects.
### Features
- 🍒 Integrated [Naive UI](https://www.naiveui.com)recommended by Evan You.
- 🍑 Integrated login, logout and permission verification.
- 🍐 Integrated multi-environment configuration, dev, test, production and github pages environments.
- 🍎 Integrated `eslint + prettier`.
- 🍌 Integrated `husky + commitlint`.
- 🍉 Integrated `Mock`.
- 🍍 Integrated `pinia`lightweight, simple and easy to use alternative to vuex.
- 📦 Integrated `unplugin` auto import.
- 🤹 Integrated `iconify` iconsupport custom svg icons.
- 🍇 Integrated `unocss`.
### Preview
[https://template.isme.top](https://template.isme.top)
[https://base.isme.top](https://base.isme.top)
### Docs
[Vue Naive Admin Docs](https://zclzone.github.io/vue-naive-admin-docs)
### Getting Started
```shell
# Recommended setup git autocrlf 为 false
git config --global core.autocrlf false
# Clone Project
git clone https://github.com/zclzone/vue-naive-admin.git
cd vue-naive-admin
# Install dependencies(Recommended use pnpm: https://pnpm.io/zh/installation)
npm i -g pnpm # Installed and can be ignored
pnpm i # or npm i
# Start
pnpm dev
```
### Build and Release
```shell
# Test Environment
pnpm build:test
# Github Environment
pnpm build:github
# Prod Environment
pnpm build
```
### Other
```shell
# eslint check
pnpm lint
# eslint check and fix
pnpm lint:fix
# PreviewNeed to build first
pnpm preview
# Commithusky+commitlint
pnpm cz
```
### Directory description
```
Vue Naive Admin
|-- .github // github相关如推送github仓库后自动部署gh pages
|-- .husky // git commit钩子
|-- .vscode // vscode编辑器相关
| |-- extensions.json // 插件推荐
| |-- settings.json // 项目级别的vscode配置优先级大于全局vscode配置
|-- build // 构建相关配置
| |-- constant.js // 构建相关的常量
| |-- utils.js // 构建相关的工具方法
| |-- config
| | |-- define.js // 注入全局常量启动或打包后将添加到window中
| | |-- proxy.js // 代理配置
| |-- plugin
| | |-- html.js // vite-plugin-html插件用于注入变量或者html标签
| | |-- mock.js // vite-plugin-mock插件处理mock
| | |-- unplugin.js // unplugin相关插件包含DefineOptions和自动导入
| |-- script // 打包完成后执行的一些node脚本不重要
| |-- build-cname.js // 自动生成cname
|-- mock // mock
| |-- utils.js // mock请求需要用到的工具方法
| |-- api // mock接口
|-- public // 公共资源文件夹下的文件会在打包后会直接加到dist根目录下
|-- settings // 项目配置
| |-- proxy-config.js // 代理配置文件
| |-- theme.json // 主题配置项,主题色等
|-- src
| |-- api // 公共api
| |-- assets // 静态资源
| | |-- images // 图片
| | |-- svg // svg图标
| |-- components // 全局组件
| | |-- common // 公共组件
| | |-- icon // icon相关组件
| | |-- page // 页面组件
| | |-- query-bar // 查询选项
| | |-- table // 封装的表格组件
| |-- composables // 封装的组合式函数
| |-- layout // 布局相关组件
| | |-- components
| | |-- AppMain.vue // 主体内容
| | |-- header // 头部
| | |-- sidebar // 侧边菜单栏
| | |-- tags // 多页签栏
| |-- router // 路由
| | |-- guard // 路由守卫
| | |-- routes // 路由列表
| |-- store // 状态管理pinia
| | |-- modules // 模块
| | |-- app // 管理页面重新加载、折叠菜单栏和keepAlive等
| | |-- permission // 权限相关,管理权限菜单
| | |-- tags // 管理多页签
| | |-- user // 用户模块,管理用户信息、登录登出
| |-- styles // 样式
| |-- utils // 封装的工具方法
| | |-- auth // 权限相关如token、跳转登录页等
| | |-- common // 通用
| | |-- http // 封装axios
| | |-- storage // 封装localStorage和sessionStorage
| |-- views // 页面
| | |-- demo // 示例
| | |-- error-page // 错误页
| | |-- login // 登录页
| | |-- workbench // 首页
| |-- App.vue
| |-- main.js
|-- .cz-config.js // git提交配置
|-- .editorconfig // 编辑器配置
|-- .env // 环境文件,所有环境都会载入
|-- .env.development // 开发环境文件
|-- .env.production // 生产环境文件
|-- .env.test // 测试环境文件
|-- .eslintignore // eslint忽略
|-- .eslintrc.js // eslint配置
|-- .gitignore // git忽略
|-- .prettierignore // prettier格式化忽略
|-- commitlint.config.js // commitlint规范配置
|-- index.html
|-- jsconfig.json // js配置
|-- LICENSE // 协议
|-- package.json // 依赖描述文件
|-- pnpm-lock.yaml // 依赖锁定文件
|-- prettier.config.js // prettier格式化配置
|-- README.md // 项目描述文档(英文)
|-- README.zh-CN.md // 项目描述文档(中文)
|-- unocss.config.js // unocss配置
|-- vite.config.js // vite配置
```
### TS version: Qs Admin
#### source code
- github: [https://github.com/zclzone/qs-admin](https://github.com/zclzone/qs-admin)
- gitee: [https://gitee.com/zclzone/qs-admin-ts](https://gitee.com/zclzone/qs-admin-ts)
#### preview
- [https://admin.isme.top](https://admin.isme.top)
- [https://zclzone.github.io/qs-admin](https://zclzone.github.io/qs-admin)
### Open source projects that use this project:
- [gin-vue-blog](https://github.com/szluyu99/gin-vue-blog): A full-stack blog project in Golang, the frontend of the blog backend is based on vue-naive-admin and integrates with a real backend service, implementing features such as backend-controlled routing.
- [vue-fastapi-admin](https://github.com/mizhexiaoxiao/vue-fastapi-admin): A Python backend management project that integrates RBAC permission management, dynamic routing, and JWT authentication, helping small and medium-sized applications to quickly establish a foundation.
### Communication group & About the author
<a href="https://blog.isme.top/about/">
<img src="https://static.isme.top/images/about.png?t=123" style="max-width: 400px" />
</a>

229
web/README.md Normal file
View File

@ -0,0 +1,229 @@
<p align="center">
<a href="https://github.com/zclzone/vue-naive-admin">
<img alt="Vue Naive Admin Logo" width="200" src="./src/assets/images/logo.png">
</a>
</p>
<p align="center">
<a href="./LICENSE"><img alt="MIT License" src="https://badgen.net/github/license/zclzone/vue-naive-admin"/></a>
</p>
<p align='center'>
<b>中文</b> |
<a href="https://github.com/zclzone/vue-naive-admin/blob/main/README.EN.md">English</a>
</p>
> 🎉🎉🎉 2.0 已开源,全新重构,全面简化,后端使用 nestjs + mysql + typeOrm[👉点击前往2.0版本 | 分支 2.x](https://github.com/zclzone/vue-naive-admin/tree/2.x),
- 体验地址: [admin.isme.top](https://admin.isme.top)
- 后端服务: [isme-nest-serve](https://github.com/zclzone/isme-nest-serve)
- 文档: [vue-naive-admin-docs](https://docs.isme.top/web/#/624306705)
### 简介
[Vue Naive Admin](https://github.com/zclzone/vue-naive-admin) 是一个 **开源免费且允许商用** 的后台管理模板,基于 `Vue3、Vite4、Pinia、Unocss 和 Naive UI` 等前端最新技术栈。相较于其他比较流行的后台管理模板,此项目更加简洁、轻量,风格清新,上手成本非常低,非常适合中小型项目或者个人项目。
### 功能
- 🍒 集成 [Naive UI](https://www.naiveui.com)
- 🍑 集成登陆、注销及权限验证
- 🍐 集成多环境配置dev、测试、生产环境
- 🍎 集成 `eslint + prettier`,代码约束和格式化统一
- 🍌 集成 `husky + commitlint`,代码提交规范化
- 🍉 集成 `mock` 接口服务dev 环境和发布环境都支持,可动态配置是否启用 mock 服务,不启用时不会加载 mock 包,减少打包体积
- 🍍 集成 `pinia`vuex 的替代方案,轻量、简单、易用
- 📦 集成 `unplugin` 插件,自动导入,解放双手,开发效率直接起飞
- 🤹 集成 `iconify` 图标,支持自定义 svg 图标, 优雅使用icon
- 🍇 集成 `unocss`antfu 开源的原子 css 解决方案,非常轻量
> ✨✨ 双十一香港特惠服务器推荐,~~**2C4G 100M** `71/年` `142/两年`~~[👉点击前往](https://blog.isme.top/vps-recommend/)
### 预览
[https://template.isme.top](https://template.isme.top)
[https://base.isme.top](https://base.isme.top)
### 文档
项目文档: [Vue Naive Admin Docs](https://zclzone.github.io/vue-naive-admin-docs)
从0到1搭建后台: [从0到1带你搭建Vite+Vue3+Pinia+Naive UI后台](https://juejin.cn/column/7093180796424421384)
如何安装pnpm: [安装pnpm](docs/安装pnpm.md)
如何使用图标: [使用图标](docs/使用图标.md)
如何使用unocss: [保熟的UnoCSS使用指北优雅使用antfu大佬的原子化CSS](https://juejin.cn/post/7142466784971456548)
### 快速开始
```shell
# 推荐配置git autocrlf 为 false本项目规范使用lf换行符此配置是为防止git自动将源文件转换为crlf
# 不清楚为什么要这样做的请参考这篇文章https://www.freesion.com/article/4532642129
git config --global core.autocrlf false
# 克隆项目
git clone https://github.com/zclzone/vue-naive-admin.git
# 进入项目目录
cd vue-naive-admin
# 安装依赖(建议使用pnpm: https://pnpm.io/zh/installation)
npm i -g pnpm # 装了可忽略
pnpm i # 或者 npm i
# 启动
pnpm dev
```
### 构建发布
```shell
# 构建测试环境
pnpm build:test
# 构建github pages环境
pnpm build:github
# 构建生产环境
pnpm build
```
### 其他指令
```shell
# eslint代码格式检查
pnpm lint
# 代码检查并修复
pnpm lint:fix
# 预览发布包效果(需先执行构建指令)
pnpm preview
# 提交代码husky+commitlint
pnpm cz
```
### 目录说明
```
Vue Naive Admin
|-- .github // github相关如推送github仓库后自动部署gh pages
|-- .husky // git commit钩子
|-- .vscode // vscode编辑器相关
| |-- extensions.json // 插件推荐
| |-- settings.json // 项目级别的vscode配置优先级大于全局vscode配置
|-- build // 构建相关配置
| |-- constant.js // 构建相关的常量
| |-- utils.js // 构建相关的工具方法
| |-- config
| | |-- define.js // 注入全局常量启动或打包后将添加到window中
| | |-- proxy.js // 代理配置
| |-- plugin
| | |-- html.js // vite-plugin-html插件用于注入变量或者html标签
| | |-- mock.js // vite-plugin-mock插件处理mock
| | |-- unplugin.js // unplugin相关插件包含DefineOptions和自动导入
| |-- script // 打包完成后执行的一些node脚本不重要
| |-- build-cname.js // 自动生成cname
|-- mock // mock
| |-- utils.js // mock请求需要用到的工具方法
| |-- api // mock接口
|-- public // 公共资源文件夹下的文件会在打包后会直接加到dist根目录下
|-- settings // 项目配置
| |-- proxy-config.js // 代理配置文件
| |-- theme.json // 主题配置项,主题色等
|-- src
| |-- api // 公共api
| |-- assets // 静态资源
| | |-- images // 图片
| | |-- svg // svg图标
| |-- components // 全局组件
| | |-- common // 公共组件
| | |-- icon // icon相关组件
| | |-- page // 页面组件
| | |-- query-bar // 查询选项
| | |-- table // 封装的表格组件
| |-- composables // 封装的组合式函数
| |-- layout // 布局相关组件
| | |-- components
| | |-- AppMain.vue // 主体内容
| | |-- header // 头部
| | |-- sidebar // 侧边菜单栏
| | |-- tags // 多页签栏
| |-- router // 路由
| | |-- guard // 路由守卫
| | |-- routes // 路由列表
| |-- store // 状态管理pinia
| | |-- modules // 模块
| | |-- app // 管理页面重新加载、折叠菜单栏和keepAlive等
| | |-- permission // 权限相关,管理权限菜单
| | |-- tags // 管理多页签
| | |-- user // 用户模块,管理用户信息、登录登出
| |-- styles // 样式
| |-- utils // 封装的工具方法
| | |-- auth // 权限相关如token、跳转登录页等
| | |-- common // 通用
| | |-- http // 封装axios
| | |-- storage // 封装localStorage和sessionStorage
| |-- views // 页面
| | |-- demo // 示例
| | |-- error-page // 错误页
| | |-- login // 登录页
| | |-- workbench // 首页
| |-- App.vue
| |-- main.js
|-- .cz-config.js // git提交配置
|-- .editorconfig // 编辑器配置
|-- .env // 环境文件,所有环境都会载入
|-- .env.development // 开发环境文件
|-- .env.production // 生产环境文件
|-- .env.test // 测试环境文件
|-- .eslintignore // eslint忽略
|-- .eslintrc.js // eslint配置
|-- .gitignore // git忽略
|-- .prettierignore // prettier格式化忽略
|-- commitlint.config.js // commitlint规范配置
|-- index.html
|-- jsconfig.json // js配置
|-- LICENSE // 协议
|-- package.json // 依赖描述文件
|-- pnpm-lock.yaml // 依赖锁定文件
|-- prettier.config.js // prettier格式化配置
|-- README.md // 项目描述文档(英文)
|-- README.zh-CN.md // 项目描述文档(中文)
|-- unocss.config.js // unocss配置
|-- vite.config.js // vite配置
```
### TS 版本: Qs Admin
#### 源码
- github: [https://github.com/zclzone/qs-admin](https://github.com/zclzone/qs-admin)
- gitee: [https://gitee.com/zclzone/qs-admin-ts](https://gitee.com/zclzone/qs-admin-ts)
#### 预览
- [https://admin.isme.top](https://admin.isme.top)
- [https://zclzone.github.io/qs-admin](https://zclzone.github.io/qs-admin)
### 使用该项目的开源项目
- [gin-vue-blog](https://github.com/szluyu99/gin-vue-blog): Golang 全栈博客项目, 博客后台的前端基于 vue-naive-admin对接真实后端服务实现了后端控制路由等特性。
- [vue-fastapi-admin](https://github.com/mizhexiaoxiao/vue-fastapi-admin): Python 后台管理项目, 融合了 RBAC 权限管理、动态路由JWT 鉴权,助力中小型应用快速搭建。
### 入群交流 & 关于作者
<a href="https://blog.isme.top/about/">
<img src="https://static.isme.top/images/about.png?t=123" style="max-width: 400px" />
</a>
### ☕ 赞助我
> 开源不易,请作者喝杯咖啡吧
<p>
<img src="https://static.isme.top/images/zhifu_weixin.jpg" style="width: 220px" />
<img src="https://static.isme.top/images/zhifu_zhifubao.jpg" style="width: 220px" />
</p>

33
web/build/constant.js Normal file
View File

@ -0,0 +1,33 @@
export const OUTPUT_DIR = 'dist'
export const PROXY_CONFIG = {
/**
* @desc 替换匹配值
* @请求路径 http://localhost:3100/api/user
* @转发路径 http://localhost:8080/user
*/
'/api': {
target: 'http://localhost:6687/api',
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp('^/api'), ''),
},
/**
* @desc 不替换匹配值
* @请求路径 http://localhost:3100/api/v2/user
* @转发路径 http://localhost:8080/api/v2/user
*/
'/api/v2': {
target: 'http://localhost:8080',
changeOrigin: true,
},
/**
* @desc 替换部分匹配值
* @请求路径 http://localhost:3100/api/v3/user
* @转发路径 http://localhost:8080/user
*/
'/api/v3': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp('^/api'), ''),
},
}

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

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

42
web/build/plugin/index.js Normal file
View File

@ -0,0 +1,42 @@
import vue from '@vitejs/plugin-vue'
/**
* * unocss插件原子css
* https://github.com/antfu/unocss
*/
import Unocss from 'unocss/vite'
// rollup打包分析插件
import visualizer from 'rollup-plugin-visualizer'
// 压缩
import viteCompression from 'vite-plugin-compression'
// vite-vuedevtool
import VueDevTools from 'vite-plugin-vue-devtools'
import { configHtmlPlugin } from './html'
import { configMockPlugin } from './mock'
import unplugin from './unplugin'
export function createVitePlugins(viteEnv, isBuild) {
const plugins = [VueDevTools(), vue(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
if (viteEnv?.VITE_USE_MOCK) {
plugins.push(configMockPlugin(isBuild))
}
if (viteEnv.VITE_USE_COMPRESS) {
plugins.push(viteCompression({ algorithm: viteEnv.VITE_COMPRESS_TYPE || 'gzip' }))
}
if (isBuild) {
plugins.push(
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
})
)
}
return plugins
}

13
web/build/plugin/mock.js Normal file
View File

@ -0,0 +1,13 @@
import { viteMockServe } from 'vite-plugin-mock'
export function configMockPlugin(isBuild) {
return viteMockServe({
mockPath: 'mock/api',
localEnabled: !isBuild,
prodEnabled: isBuild,
injectCode: `
import { setupProdMockServer } from '../mock';
setupProdMockServer();
`,
})
}

View File

@ -0,0 +1,46 @@
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
/**
* * unplugin-icons插件自动引入iconify图标
* usage: https://github.com/antfu/unplugin-icons
* 图标库: https://icones.js.org/
*/
import Icons from 'unplugin-icons/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import { getSrcPath } from '../utils'
const customIconPath = resolve(getSrcPath(), 'assets/svg')
export default [
AutoImport({
imports: ['vue', 'vue-router'],
dts: false,
}),
Icons({
compiler: 'vue3',
customCollections: {
custom: FileSystemIconLoader(customIconPath),
},
scale: 1,
defaultClass: 'inline-block',
}),
Components({
resolvers: [
NaiveUiResolver(),
IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' }),
],
dts: false,
}),
createSvgIconsPlugin({
iconDirs: [customIconPath],
symbolId: 'icon-custom-[dir]-[name]',
inject: 'body-last',
customDomId: '__CUSTOM_SVG_ICON__',
}),
]

View File

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

14
web/build/script/index.js Normal file
View File

@ -0,0 +1,14 @@
import chalk from 'chalk'
import { runBuildCNAME } from './build-cname'
export const runBuild = async () => {
try {
runBuildCNAME()
console.log(`${chalk.cyan('build successfully!')}`)
} catch (error) {
console.log(chalk.red('vite build error:\n' + error))
process.exit(1)
}
}
runBuild()

70
web/build/utils.js Normal file
View File

@ -0,0 +1,70 @@
import fs from 'fs'
import path from 'path'
import dotenv from 'dotenv'
/**
* * 项目根路径
* @description 结尾不带/
*/
export function getRootPath() {
return path.resolve(process.cwd())
}
/**
* * 项目src路径
* @param srcName src目录名称(默认: "src")
* @description 结尾不带斜杠
*/
export function getSrcPath(srcName = 'src') {
return path.resolve(getRootPath(), srcName)
}
export function convertEnv(envOptions) {
const result = {}
if (!envOptions) return result
for (const envKey in envOptions) {
let envVal = envOptions[envKey]
if (['true', 'false'].includes(envVal)) envVal = envVal === 'true'
if (['VITE_PORT'].includes(envKey)) envVal = +envVal
result[envKey] = envVal
}
return result
}
/**
* 获取当前环境下生效的配置文件名
*/
function getConfFiles() {
const script = process.env.npm_lifecycle_script
const reg = new RegExp('--mode ([a-z_\\d]+)')
const result = reg.exec(script)
if (result) {
const mode = result[1]
return ['.env', '.env.local', `.env.${mode}`]
}
return ['.env', '.env.local', '.env.production']
}
export function getEnvConfig(match = 'VITE_', confFiles = getConfFiles()) {
let envConfig = {}
confFiles.forEach((item) => {
try {
if (fs.existsSync(path.resolve(process.cwd(), item))) {
const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item)))
envConfig = { ...envConfig, ...env }
}
} catch (e) {
console.error(`Error in parsing ${item}`, e)
}
})
const reg = new RegExp(`^(${match})`)
Object.keys(envConfig).forEach((key) => {
if (!reg.test(key)) {
Reflect.deleteProperty(envConfig, key)
}
})
return envConfig
}

26
web/commitlint.config.js Normal file
View File

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

26
web/docker/web.conf Normal file
View File

@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
proxy_send_timeout 600s; # 设置发送超时时间,
proxy_read_timeout 600s; # 设置读取超时时间。
# 前端打包好的dist目录文件
root /data/;
location / {
try_files $uri $uri/ /index.html;
}
# 若新增后端路由前缀注意在此处添加(|新增)
location ^~ /api/ {
proxy_pass http://wireguard-srv:6687;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-Proto $scheme;
}
}

3
web/docs/使用unocss.md Normal file
View File

@ -0,0 +1,3 @@
推荐阅读作者在掘金的文章:
[保熟的UnoCSS使用指北优雅使用antfu大佬的原子化CSS](https://juejin.cn/post/7142466784971456548)

40
web/docs/使用图标.md Normal file
View File

@ -0,0 +1,40 @@
## 使用 iconify 图标
首先去图标库地址:[icones](https://icones.js.org/) 找合适的图标
### 1. 结合 unocss 使用
```html
<i i-carbon-sun />
<i class="i-carbon-sun" />
```
### 2. 结合插件 unplugin-icons 自定义标签使用
`<icon-[iconify图标名称]`
```html
<icon-ant-design:fullscreen-exit-outlined />
<icon-ant-design:fullscreen-outlined />
```
这种方式还支持自定义 svg 图标,本项目自定义 svg 图标固定放在 src/assets/svg 下
`<icon-custom-[svg图标文件名]`
```
<icon-custom-logo />
```
具体配置参看 build/plugin/unplugin.js
### 3. 结合 Naive UI 的 NIcon 组件封装使用
```html
<!-- iconify图标 -->
<TheIcon icon="material-symbols:delete-outline" />
<!-- 自定义svg图标 -->
<TheIcon icon="logo" type="custom" />
```
封装组件参看 src/components/icon

32
web/docs/安装pnpm.md Normal file
View File

@ -0,0 +1,32 @@
## 安装pnpm
### 使用Corepack安装推荐
从 v16.13 开始Node.js 发布了 Corepack 来管理包管理器。 这是一项实验性功能,需要通过运行如下脚本来启用它:
```
npx corepack enable // 可能需要管理员权限
```
这将自动在您的系统上安装 pnpm。 但是,它可能不是最新版本的 pnpm。 若要升级,请检查[最新的 pnpm 版本](https://github.com/pnpm/pnpm/releases/latest) 并运行,如 7.14.0
```
corepack prepare pnpm@7.14.0 --activate
```
如果是 Node.js v16.17 或者更新的版本,可以直接安装最新版本的 pnpm
```
corepack prepare pnpm@latest --activate
```
### 使用npm安装
```
npm i -g pnpm
```
更新,卸了重新装
```
npm uninstall -g pnpm
npm i -g pnpm
```

35
web/index.html Normal file
View File

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

14
web/jsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": ["src/*"],
"~/*": ["./*"]
},
"jsx": "preserve",
"allowJs": true
},
"exclude": ["node_modules", "dist"]
}

40
web/mock/api/auth.js Normal file
View File

@ -0,0 +1,40 @@
import { resolveToken } from '../utils'
const token = {
admin: 'admin',
editor: 'editor',
}
export default [
{
url: '/api/auth/login',
method: 'post',
response: ({ body }) => {
if (['admin', 'editor'].includes(body?.name)) {
return {
code: 0,
data: {
token: token[body.name],
},
}
} else {
return {
code: -1,
message: '没有此用户',
}
}
},
},
{
url: '/api/auth/refreshToken',
method: 'post',
response: ({ headers }) => {
return {
code: 0,
data: {
token: resolveToken(headers?.authorization),
},
}
},
},
]

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

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

138
web/mock/api/post.js Normal file
View File

@ -0,0 +1,138 @@
const posts = [
{
title: '使用纯css优雅配置移动端rem布局',
author: '大脸怪',
category: 'Css',
description: '通常配置rem布局会使用js进行处理比如750的设计稿会这样...',
content: '通常配置rem布局会使用js进行处理比如750的设计稿会这样',
isRecommend: true,
isPublish: true,
createDate: '2021-11-04T04:03:36.000Z',
updateDate: '2021-11-04T04:03:36.000Z',
},
{
title: 'Vue2&Vue3项目风格指南',
author: 'Ronnie',
category: 'Vue',
description: '总结的Vue2和Vue3的项目风格',
content: '### 1. 命名风格\n\n> 文件夹如果是由多个单词组成,应该始终是横线连接 ',
isRecommend: true,
isPublish: true,
createDate: '2021-10-25T08:57:47.000Z',
updateDate: '2022-02-28T04:02:39.000Z',
},
{
title: '如何优雅的给图片添加水印',
author: '大脸怪',
category: 'JavaScript',
description: '优雅的给图片添加水印',
content: '我之前写过一篇文章记录了一次上传图片的优化史',
isRecommend: true,
isPublish: true,
createDate: '2021-06-24T18:46:19.000Z',
updateDate: '2021-09-23T07:51:22.000Z',
},
{
title: '前端缓存的理解',
author: '大脸怪',
category: 'Http',
description: '谈谈前端缓存的理解',
content:
'> 背景\n\n公司有个vue-cli3移动端web项目发版更新后发现部分用户手机在钉钉内置浏览器打开出现了缓存',
isRecommend: true,
isPublish: true,
createDate: '2021-06-10T18:51:19.000Z',
updateDate: '2021-09-17T09:33:24.000Z',
},
{
title: 'Promise的五个静态方法',
author: '大脸怪',
category: 'JavaScript',
description: '简单介绍下在 Promise 类中有5 种静态方法及它们的使用场景',
content:
'## 1. Promise.all\n\n并行执行多个 promise并等待所有 promise 都准备就绪。再对它们进行处理。',
isRecommend: true,
isPublish: true,
createDate: '2021-02-22T22:37:06.000Z',
updateDate: '2021-09-17T09:33:24.000Z',
},
]
export default [
{
url: '/api/posts',
method: 'get',
response: (data = {}) => {
const { title, pageNo, pageSize } = data.query
let pageData = []
let total = 60
const filterData = posts.filter(
(item) => item.title.includes(title) || (!title && title !== 0)
)
if (filterData.length) {
if (pageSize) {
while (pageData.length < pageSize) {
pageData.push(filterData[Math.round(Math.random() * (filterData.length - 1))])
}
} else {
pageData = filterData
}
pageData = pageData.map((item, index) => ({
id: pageSize * (pageNo - 1) + index + 1,
...item,
}))
} else {
total = 0
}
return {
code: 0,
message: 'ok',
data: {
pageData,
total,
pageNo,
pageSize,
},
}
},
},
{
url: '/api/post',
method: 'post',
response: ({ body }) => {
return {
code: 0,
message: 'ok',
data: body,
}
},
},
{
url: '/api/post/:id',
method: 'put',
response: ({ query, body }) => {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
body,
},
}
},
},
{
url: '/api/post/:id',
method: 'delete',
response: ({ query }) => {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
},
}
},
},
]

39
web/mock/api/user.js Normal file
View File

@ -0,0 +1,39 @@
import { resolveToken } from '../utils'
const users = {
admin: {
id: 1,
name: '大脸怪(admin)',
avatar: 'https://static.isme.top/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['admin'],
},
editor: {
id: 2,
name: '大脸怪(editor)',
avatar: 'https://static.isme.top/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['editor'],
},
guest: {
id: 3,
name: '访客(guest)',
avatar: 'https://static.isme.top/images/avatar.jpg',
role: [],
},
}
export default [
{
url: '/api/user',
method: 'get',
response: ({ headers }) => {
const token = resolveToken(headers?.authorization)
return {
code: 0,
data: {
...(users[token] || users.guest),
},
}
},
},
]

6
web/mock/index.js Normal file
View File

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

12
web/mock/utils.js Normal file
View File

@ -0,0 +1,12 @@
export function resolveToken(authorization) {
/**
* * jwt token
* * Bearer + token
* ! 认证方案: Bearer
*/
const reqTokenSplit = authorization.split(' ')
if (reqTokenSplit.length === 2) {
return reqTokenSplit[1]
}
return ''
}

82
web/package.json Normal file
View File

@ -0,0 +1,82 @@
{
"name": "vue-naive-admin",
"version": "1.0.0",
"scripts": {
"build": "vite build",
"build:github": "vite build --mode github && esno ./build/script",
"build:test": "vite build --mode test",
"cz": "cz",
"dev": "vite",
"lint": "eslint --ext .js,.vue .",
"lint:fix": "eslint --fix --ext .js,.vue .",
"lint:staged": "lint-staged",
"prepare": "husky install",
"preview": "vite preview"
},
"lint-staged": {
"*.{js,vue}": [
"eslint --ext .js,.vue ."
]
},
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
}
},
"eslintConfig": {
"extends": [
"@zclzone",
"@unocss",
".eslint-global-variables.json"
]
},
"dependencies": {
"@unocss/eslint-config": "^0.55.7",
"@vueuse/core": "^10.4.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "5.1.12",
"axios": "^1.5.1",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"lodash-es": "^4.17.21",
"md-editor-v3": "^4.7.0",
"mockjs": "^1.1.0",
"pinia": "^2.1.6",
"vite": "^4.4.11",
"vue": "3.3.4",
"vue-echarts": "^6.6.1",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@commitlint/cli": "^17.7.2",
"@commitlint/config-conventional": "^17.7.0",
"@iconify/json": "^2.2.125",
"@iconify/vue": "^4.1.1",
"@unocss/preset-rem-to-px": "^0.55.7",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/compiler-sfc": "^3.3.4",
"@zclzone/eslint-config": "^0.0.5",
"chalk": "^5.3.0",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"cz-customizable": "^7.0.0",
"dotenv": "^16.3.1",
"esno": "^0.17.0",
"fs-extra": "^11.1.1",
"husky": "^8.0.3",
"lint-staged": "^14.0.1",
"naive-ui": "^2.39.0",
"rollup-plugin-visualizer": "^5.9.2",
"sass": "^1.69.0",
"unocss": "0.55.3",
"unplugin-auto-import": "^0.16.6",
"unplugin-icons": "^0.16.6",
"unplugin-vue-components": "^0.25.2",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.0",
"vite-plugin-mock": "2.9.6",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-devtools": "1.0.0-rc.7"
}
}

8992
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

1
web/public/favicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 825 B

View File

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

View File

@ -0,0 +1,9 @@
function addThemeColorCssVars() {
const key = '__THEME_COLOR__'
const defaultColor = '#316c72'
const themeColor = window.localStorage.getItem(key) || defaultColor
const cssVars = `--primary-color: ${themeColor}`
document.documentElement.style.cssText = cssVars
}
addThemeColorCssVars()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

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

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

37
web/settings/theme.json Normal file
View File

@ -0,0 +1,37 @@
{
"header": {
"height": 60
},
"tags": {
"visible": true,
"height": 50
},
"naiveThemeOverrides": {
"common": {
"primaryColor": "#316C72FF",
"primaryColorHover": "#316C72E3",
"primaryColorPressed": "#2B4C59FF",
"primaryColorSuppl": "#316C72E3",
"infoColor": "#2080F0FF",
"infoColorHover": "#4098FCFF",
"infoColorPressed": "#1060C9FF",
"infoColorSuppl": "#4098FCFF",
"successColor": "#18A058FF",
"successColorHover": "#36AD6AFF",
"successColorPressed": "#0C7A43FF",
"successColorSuppl": "#36AD6AFF",
"warningColor": "#F0A020FF",
"warningColorHover": "#FCB040FF",
"warningColorPressed": "#C97C10FF",
"warningColorSuppl": "#FCB040FF",
"errorColor": "#D03050FF",
"errorColorHover": "#DE576DFF",
"errorColorPressed": "#AB1F3FFF",
"errorColorSuppl": "#DE576DFF"
}
}
}

11
web/src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<AppProvider>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</AppProvider>
</template>
<script setup>
import AppProvider from '@/components/common/AppProvider.vue'
</script>

9
web/src/api/user.js Normal file
View File

@ -0,0 +1,9 @@
import { request } from '@/utils'
export default {
getUser: () => request.get('/user/info'), // 获取当前登陆用户信息
logout: () => request.post('/user/logout'), // 退出登陆
changePassword: (data) => request.put('/user/change-password',data), // 修改密码
generateAvatar: () => request.post('/user/generate-avatar'), // 生成头像
addOrUpdateUser: (data) => request.post('/user',data), // 新增/编辑用户信息
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

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

View File

@ -0,0 +1,30 @@
<template>
<n-config-provider
wh-full
:locale="zhCN"
:date-locale="dateZhCN"
:theme="appStore.isDark ? darkTheme : undefined"
:theme-overrides="naiveThemeOverrides"
>
<slot />
</n-config-provider>
</template>
<script setup>
import { zhCN, dateZhCN, darkTheme } from 'naive-ui'
import { useCssVar } from '@vueuse/core'
import { kebabCase } from 'lodash-es'
import { naiveThemeOverrides } from '~/settings'
import { useAppStore } from '@/store'
const appStore = useAppStore()
function setupCssVar() {
const common = naiveThemeOverrides.common
for (const key in common) {
useCssVar(`--${kebabCase(key)}`, document.documentElement).value = common[key] || ''
if (key === 'primaryColor') window.localStorage.setItem('__THEME_COLOR__', common[key] || '')
}
}
setupCssVar()
</script>

View File

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

View File

@ -0,0 +1,22 @@
<script setup>
/** 自定义图标 */
const props = defineProps({
/** 图标名称(assets/svg下的文件名) */
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
})
</script>
<template>
<TheIcon type="custom" v-bind="props" />
</template>

View File

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

View File

@ -0,0 +1,33 @@
<script setup>
import { renderIcon, renderCustomIcon } from '@/utils'
const props = defineProps({
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
/** iconify | custom */
type: {
type: String,
default: 'iconify',
},
})
const iconCom = computed(() =>
props.type === 'iconify'
? renderIcon(props.icon, { size: props.size, color: props.color })
: renderCustomIcon(props.icon, { size: props.size, color: props.color })
)
</script>
<template>
<component :is="iconCom" />
</template>

View File

@ -0,0 +1,18 @@
<template>
<transition name="fade-slide" mode="out-in" appear>
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15 dark:bg-hex-121212">
<slot />
<AppFooter v-if="showFooter" mt-15 />
<n-back-top :bottom="20" />
</section>
</transition>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
})
</script>

View File

@ -0,0 +1,229 @@
<template>
<AppPage :show-footer="showFooter">
<header v-if="showHeader" mt-10 mb-20 min-h-45 flex items-center justify-between px-15>
<slot v-if="$slots.header" name="header" />
<template v-else>
<n-card style="margin-left: 1.65%; width: 97.25%">
<n-space justify="end">
<n-button size="small" style="float: right" type="info" @click="addClient">添加</n-button>
<n-button size="small" style="float: right" type="primary" @click="refreshList">刷新</n-button>
</n-space>
</n-card>
<slot name="action" />
</template>
</header>
<div>
<slot />
</div>
</AppPage>
<n-modal
style="width: 25%"
v-model:show="addModalShow"
preset="card"
title="添加"
header-style="margin-left: 40%"
>
<n-form
ref="addModalFormRef"
:rules="addModalFormRules"
:model="addModalForm"
label-placement="left"
label-width="auto"
label-align="left"
require-mark-placement="right"
>
<n-form-item label="名称" path="name">
<n-input v-model:value="addModalForm.name"/>
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input v-model:value="addModalForm.email"/>
</n-form-item>
<n-form-item label="IP" path="addModalForm.ipAllocation">
<n-select
v-model:value="addModalForm.ipAllocation"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="可访问IP" path="allowedIps">
<n-select
v-model:value="addModalForm.allowedIps"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="可访问IP扩展" path="extraAllowedIps">
<n-select
v-model:value="addModalForm.extraAllowedIps"
filterable
multiple
tag
placeholder="输入,按回车确认"
:show-arrow="false"
:show="false"
/>
</n-form-item>
<n-form-item label="服务端DNS" path="useServerDns">
<n-radio value="1" :checked="addModalForm.useServerDns === 1" @change="addModalForm.useServerDns = 1"></n-radio>
<n-radio value="0" :checked="addModalForm.useServerDns === 0" @change="addModalForm.useServerDns = 0"></n-radio>
</n-form-item>
<n-form-item label="公钥" path="keys.publicKey">
<n-input v-model:value="addModalForm.keys.publicKey"></n-input>
</n-form-item>
<n-form-item label="私钥" path="keys.privateKey">
<n-input v-model:value="addModalForm.keys.privateKey"></n-input>
</n-form-item>
<n-form-item label="共享密钥" path="keys.presharedKey">
<n-input v-model:value="addModalForm.keys.presharedKey"></n-input>
</n-form-item>
<n-form-item>
<n-button style="margin-left: 28%" size="small" type="info" @click="generateKeys">生成密钥对</n-button>
</n-form-item>
<n-form-item label="状态" path="editModalForm.enabled">
<n-radio-group :value="addModalForm.enabled">
<n-radio :value="1" :checked="addModalForm.enabled === 1" @change="addModalForm.enabled = 1">启用</n-radio>
<n-radio :value="0" :checked="addModalForm.enabled === 0" @change="addModalForm.enabled = 0">禁用</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="离线监听" path="offlineMonitoring">
<n-radio-group :value="addModalForm.offlineMonitoring">
<n-radio :value="1" :checked="addModalForm.offlineMonitoring === 1" @change="addModalForm.offlineMonitoring = 1">启用</n-radio>
<n-radio :value="0" :checked="addModalForm.offlineMonitoring === 0" @change="addModalForm.offlineMonitoring = 0">禁用</n-radio>
</n-radio-group>
</n-form-item>
<n-button type="info" style="margin-left: 40%" @click="confirmAddClient()">确认</n-button>
</n-form>
</n-modal>
</template>
<script setup>
import clientApi from '@/views/client/api'
import { NButton } from 'naive-ui'
import { debounce } from '@/utils'
defineProps({
showFooter: {
type: Boolean,
default: false,
},
showHeader: {
type: Boolean,
default: true,
},
title: {
type: String,
default: undefined,
},
})
const route = useRoute()
const addModalShow = ref(false)
// form ref
const addModalFormRef = ref()
// form rules
const addModalFormRules = {
name: [
{ required: true, message: '名称不能为空', trigger: 'blur' }
],
ipAllocation: [
{ required: true, message: '客户端IP不能为空', trigger: 'change' }
],
keys: {
privateKey: [
{ required: true, message: '密钥不能为空', trigger: 'blur' }
],
publicKey: [
{ required: true, message: '公钥不能为空', trigger: 'blur' }
],
presharedKey: [
{ required: true, message: '共享密钥不能为空', trigger: 'blur' }
],
},
enabled: [
{ required: true, message: '状态不能为空', trigger: 'change' }
]
}
//
const addModalForm = ref({
id: '',
name: '',
email: '',
ipAllocation: [],
allowedIps: [],
extraAllowedIps: [],
useServerDns: 0,
keys: {
privateKey: '',
publicKey: '',
presharedKey: ''
},
enabled: 1,
offlineMonitoring: 1
})
//
async function confirmAddClient() {
addModalFormRef.value.validate(async (valid) => {
if (valid) {
return valid
}
clientApi.saveClient(addModalForm.value).then((response) => {
if (response.data.code === 200) {
refreshList()
addModalShow.value = false
}
})
})
}
//
const addClient = debounce(() => {
generateClientIPS()
addModalShow.value = true
},300)
//
function generateKeys() {
clientApi.generateClientKeys().then(res => {
if (res.data.code === 200) {
addModalForm.value.keys.privateKey = res.data.data.privateKey
addModalForm.value.keys.publicKey = res.data.data.publicKey
addModalForm.value.keys.presharedKey = res.data.data.presharedKey
}
})
}
// IP
function generateClientIPS() {
clientApi.generateClientIP().then(res => {
if (res.data.code === 200) {
addModalForm.value.ipAllocation = res.data.data.clientIPS
addModalForm.value.allowedIps = res.data.data.serverIPS
generateKeys()
}
})
}
//
function refreshList() {
clientApi.clientList({
current: 1,
size: 8
})
}
</script>

View File

@ -0,0 +1,33 @@
<template>
<AppPage :show-footer="showFooter">
<header v-if="showHeader" mt-10 mb-20 min-h-45 flex items-center justify-between px-15>
<slot v-if="$slots.header" name="header" />
<template v-else>
<h3>{{ title }}</h3>
<slot name="action" />
</template>
</header>
<div>
<slot />
</div>
</AppPage>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
showHeader: {
type: Boolean,
default: true,
},
title: {
type: String,
default: undefined,
},
})
const route = useRoute()
</script>

View File

@ -0,0 +1,27 @@
<template>
<div
bg="#fafafc"
min-h-60
flex
items-start
justify-between
b-1
rounded-8
p-15
bc-ccc
dark:bg-black
h-60
>
<n-space wrap :size="[35, 15]">
<slot />
</n-space>
<div flex-shrink-0>
<n-button ml-20 size="small" type="primary" @click="emit('search')">搜索</n-button>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['search', 'reset'])
</script>

View File

@ -0,0 +1,34 @@
<template>
<div flex items-center>
<label
v-if="!isNullOrWhitespace(label)"
w-80
flex-shrink-0
:style="{ width: labelWidth + 'px' }"
>
{{ label }}
</label>
<div :style="{ width: contentWidth + 'px' }" flex-shrink-0>
<slot />
</div>
</div>
</template>
<script setup>
import { isNullOrWhitespace } from '@/utils'
defineProps({
label: {
type: String,
default: '',
},
labelWidth: {
type: Number,
default: 80,
},
contentWidth: {
type: Number,
default: 220,
},
})
</script>

View File

@ -0,0 +1,55 @@
<template>
<n-modal
v-model:show="show"
:style="{ width }"
preset="card"
:title="title"
size="huge"
:bordered="false"
>
<slot />
<template v-if="showFooter" #footer>
<footer flex justify-end>
<slot name="footer">
<n-button @click="show = false">取消</n-button>
<n-button :loading="loading" ml-20 type="primary" @click="emit('onSave')">保存</n-button>
</slot>
</footer>
</template>
</n-modal>
</template>
<script setup>
const props = defineProps({
width: {
type: String,
default: '600px',
},
title: {
type: String,
default: '',
},
showFooter: {
type: Boolean,
default: true,
},
visible: {
type: Boolean,
required: true,
},
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:visible', 'onSave'])
const show = computed({
get() {
return props.visible
},
set(v) {
emit('update:visible', v)
},
})
</script>

View File

@ -0,0 +1,149 @@
<template>
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
<slot name="queryBar" />
</QueryBar>
<n-data-table
:remote="remote"
:loading="loading"
:scroll-x="scrollX"
:columns="columns"
:data="tableData"
:row-key="(row) => row[rowKey]"
:pagination="isPagination ? pagination : false"
@update:checked-row-keys="onChecked"
@update:page="onPageChange"
/>
</template>
<script setup>
import { utils, writeFile } from 'xlsx'
const props = defineProps({
/**
* @remote true: 后端分页 false 前端分页
*/
remote: {
type: Boolean,
default: true,
},
/**
* @remote 是否分页
*/
isPagination: {
type: Boolean,
default: true,
},
scrollX: {
type: Number,
default: 1200,
},
rowKey: {
type: String,
default: 'id',
},
columns: {
type: Array,
required: true,
},
/** queryBar中的参数 */
queryItems: {
type: Object,
default() {
return {}
},
},
/** 补充参数(可选) */
extraParams: {
type: Object,
default() {
return {}
},
},
/**
* ! 约定接口入参出参
* * 分页模式需约定分页接口入参
* @pageSize 分页参数一页展示多少条默认10
* @pageNo 分页参数页码默认1
* * 需约定接口出参
* @pageData 分页模式必须,非分页模式如果没有pageData则取上一层data
* @total 分页模式必须非分页模式如果没有total则取上一层data.length
*/
getData: {
type: Function,
required: true,
},
})
const emit = defineEmits(['update:queryItems', 'onChecked', 'onDataChange'])
const loading = ref(false)
const initQuery = { ...props.queryItems }
const tableData = ref([])
const pagination = reactive({ page: 1, pageSize: 10 })
async function handleQuery() {
try {
loading.value = true
let paginationParams = {}
// 使,
if (props.isPagination && props.remote) {
paginationParams = { pageNo: pagination.page, pageSize: pagination.pageSize }
}
const { data } = await props.getData({
...props.queryItems,
...props.extraParams,
...paginationParams,
})
tableData.value = data?.pageData || data
pagination.itemCount = data.total ?? data.length
} catch (error) {
tableData.value = []
pagination.itemCount = 0
} finally {
emit('onDataChange', tableData.value)
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
handleQuery()
}
async function handleReset() {
const queryItems = { ...props.queryItems }
for (const key in queryItems) {
queryItems[key] = ''
}
emit('update:queryItems', { ...queryItems, ...initQuery })
await nextTick()
pagination.page = 1
handleQuery()
}
function onPageChange(currentPage) {
pagination.page = currentPage
if (props.remote) {
handleQuery()
}
}
function onChecked(rowKeys) {
if (props.columns.some((item) => item.type === 'selection')) {
emit('onChecked', rowKeys)
}
}
function handleExport(columns = props.columns, data = tableData.value) {
if (!data?.length) return $message.warning('没有数据')
const columnsData = columns.filter((item) => !!item.title && !item.hideInExcel)
const thKeys = columnsData.map((item) => item.key)
const thData = columnsData.map((item) => item.title)
const trData = data.map((item) => thKeys.map((key) => item[key]))
const sheet = utils.aoa_to_sheet([thData, ...trData])
const workBook = utils.book_new()
utils.book_append_sheet(workBook, sheet, '数据报表')
writeFile(workBook, '数据报表.xlsx')
}
defineExpose({
handleSearch,
handleReset,
handleExport,
})
</script>

View File

@ -0,0 +1 @@
export { default as useCRUD } from './useCRUD'

View File

@ -0,0 +1,103 @@
import { isNullOrWhitespace } from '@/utils'
const ACTIONS = {
view: '查看',
edit: '编辑',
add: '新增',
}
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
const modalVisible = ref(false)
const modalAction = ref('')
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
const modalLoading = ref(false)
const modalFormRef = ref(null)
const modalForm = ref({ ...initForm })
/** 新增 */
function handleAdd() {
modalAction.value = 'add'
modalVisible.value = true
modalForm.value = { ...initForm }
}
/** 修改 */
function handleEdit(row) {
modalAction.value = 'edit'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 查看 */
function handleView(row) {
modalAction.value = 'view'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 保存 */
function handleSave() {
if (!['edit', 'add'].includes(modalAction.value)) {
modalVisible.value = false
return
}
modalFormRef.value?.validate(async (err) => {
if (err) return
const actions = {
add: {
api: () => doCreate(modalForm.value),
cb: () => $message.success('新增成功'),
},
edit: {
api: () => doUpdate(modalForm.value),
cb: () => $message.success('编辑成功'),
},
}
const action = actions[modalAction.value]
try {
modalLoading.value = true
const data = await action.api()
action.cb()
modalLoading.value = modalVisible.value = false
data && refresh(data)
} catch (error) {
modalLoading.value = false
}
})
}
/** 删除 */
function handleDelete(id, confirmOptions) {
if (isNullOrWhitespace(id)) return
$dialog.confirm({
content: '确定删除?',
async confirm() {
try {
modalLoading.value = true
const data = await doDelete(id)
$message.success('删除成功')
modalLoading.value = false
refresh(data)
} catch (error) {
modalLoading.value = false
}
},
...confirmOptions,
})
}
return {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
}
}

View File

@ -0,0 +1,16 @@
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive :include="keepAliveNames">
<component :is="Component" v-if="!tagStore.reloading" :key="route.fullPath" />
</KeepAlive>
</router-view>
</template>
<script setup>
import { useTagsStore } from '@/store'
const tagStore = useTagsStore()
const keepAliveNames = computed(() => {
return tagStore.tags.filter((item) => item.keepAlive).map((item) => item.name)
})
</script>

View File

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

View File

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

View File

@ -0,0 +1,11 @@
<template>
<n-icon mr-20 size="18" style="cursor: pointer" @click="handleLinkClick">
<icon-simple-icons:gitee />
</n-icon>
</template>
<script setup>
function handleLinkClick() {
window.open('https://gitee.com/isme-admin/vue-naive-admin')
}
</script>

View File

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

View File

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

View File

@ -0,0 +1,82 @@
<template>
<n-popover trigger="click" placement="bottom" @update:show="handlePopoverShow">
<template #trigger>
<n-badge :value="count" mr-20 cursor-pointer>
<n-icon size="18" color-black dark="color-hex-fff">
<icon-material-symbols:notifications-outline />
</n-icon>
</n-badge>
</template>
<n-tabs v-model:value="activeTab" type="line" justify-content="space-around" animated>
<n-tab-pane
v-for="tab in tabs"
:key="tab.name"
:name="tab.name"
:tab="tab.title + `(${tab.messages.length})`"
>
<ul class="cus-scroll-y max-h-200 w-220">
<li
v-for="(item, index) in tab.messages"
:key="index"
class="flex-col py-12"
border-t="1 solid gray-200"
:style="index > 0 ? '' : 'border: none;'"
>
<span mb-4 text-ellipsis>{{ item.content }}</span>
<span text-hex-bbb>{{ item.time }}</span>
</li>
</ul>
</n-tab-pane>
</n-tabs>
</n-popover>
</template>
<script setup>
import { formatDateTime } from '@/utils'
const activeTab = ref('')
const tabs = [
{
name: 'zan',
title: '点赞',
messages: [
{ content: '你的文章《XX》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《YY》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《AA》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《BB》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《CC》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《DD》收到一条点赞', time: formatDateTime() },
],
},
{
name: 'star',
title: '关注',
messages: [
{ content: '张三 关注了你', time: formatDateTime() },
{ content: '王五 关注了你', time: formatDateTime() },
],
},
{
name: 'comment',
title: '评论',
messages: [
{ content: '张三 评论了你的文章《XX》"学到了"', time: formatDateTime() },
{ content: '李四 评论了你的文章《YY》"不如Vue"', time: formatDateTime() },
],
},
]
const count = ref(tabs.map((item) => item.messages).flat().length)
watch(activeTab, (v) => {
if (count === 0) return
const tabIndex = tabs.findIndex((item) => item.name === v)
count.value -= tabs[tabIndex].messages.length
if (count.value < 0) count.value = 0
})
function handlePopoverShow(show) {
if (show && !activeTab.value) {
activeTab.value = tabs[0]?.name
}
}
</script>

View File

@ -0,0 +1,18 @@
<script setup>
import { useAppStore } from '@/store'
import { useDark, useToggle } from '@vueuse/core'
const appStore = useAppStore()
const isDark = useDark()
const toggleDark = () => {
appStore.toggleDark()
useToggle(isDark)()
}
</script>
<template>
<n-icon mr-20 cursor-pointer size="18" @click="toggleDark">
<icon-mdi-moon-waning-crescent v-if="isDark" />
<icon-mdi-white-balance-sunny v-else />
</n-icon>
</template>

View File

@ -0,0 +1,218 @@
<template>
<n-dropdown :options="options" @select="handleSelect">
<div flex cursor-pointer items-center>
<img :src="userStore.avatar" mr10 h-35 w-35 rounded-full />
<span>{{ userStore.nickname }}</span>
</div>
</n-dropdown>
<n-modal
v-model:show="showChangePwdModel"
transform-origin="center"
preset="card"
title="修改密码"
:bordered="false"
size="large"
style="width: 400px"
header-style="text-align: center"
>
<n-form label-placement="top"
ref="changePasswordFormRef"
:rules="changePasswordFormRules"
:model="changePasswordFormModel">
<n-form-item label="原密码" path="originalPassword">
<n-input type="password" show-password-on="click" v-model:value="changePasswordFormModel.originalPassword"></n-input>
</n-form-item>
<n-form-item label="新密码" path="newPassword">
<n-input type="password" show-password-on="click" v-model:value="changePasswordFormModel.newPassword"></n-input>
</n-form-item>
<n-form-item label="确认密码" path="confirmPassword">
<n-input type="password" show-password-on="click" v-model:value="changePasswordFormModel.confirmPassword"></n-input>
</n-form-item>
<n-button type="primary" @click="changePasswordHandle()">确认</n-button>
</n-form>
</n-modal>
<n-modal
v-model:show="showInfoModel"
transform-origin="center"
preset="card"
:title="infoFormModel.nickname || '个人资料'"
:bordered="false"
size="large"
style="width: 400px"
header-style="text-align: center"
>
<n-form label-placement="top"
ref="infoFormRef"
:rules="infoFormRules"
:model="infoFormModel">
<n-form-item path="avatar">
<n-avatar
round
style="cursor: pointer;margin-left: 36%"
:size="78"
:src="showAvatar"
@click="changeAvatar()"
></n-avatar>
</n-form-item>
<n-form-item label="账号" path="account">
<n-input disabled v-model:value="infoFormModel.account"></n-input>
</n-form-item>
<n-form-item label="昵称" path="nickname">
<n-input v-model:value="infoFormModel.nickname"></n-input>
</n-form-item>
<n-form-item label="联系方式" path="contact">
<n-input v-model:value="infoFormModel.contact"></n-input>
</n-form-item>
<n-button type="primary" @click="updateInfo()">确认</n-button>
</n-form>
</n-modal>
</template>
<script setup>
import { useUserStore } from '@/store'
import { renderIcon } from '@/utils'
import api from '@/api/user'
const userStore = useUserStore()
const options = [
{
label: "个人信息",
key: 'info',
icon: renderIcon('streamline:information-desk-solid', { size: '14px' })
},
{
label: "修改密码",
key: 'change-password',
icon: renderIcon('mdi:lock', { size: '14px' }),
},
{
label: '退出登录',
key: 'logout',
icon: renderIcon('mdi:exit-to-app', { size: '14px' }),
}
]
const showAvatar = ref('')
const showChangePwdModel = ref(false)
const showInfoModel = ref(false)
const changePasswordFormRef = ref()
const infoFormRef = ref()
//
const changePasswordFormModel = ref({
originalPassword: '',
newPassword: '',
confirmPassword: ''
})
//
const infoFormModel = ref({
id: '',
account: '',
nickname: '',
avatar: '',
contact: '',
isAdmin: 0,
status: 1
})
//
const changePasswordFormRules = {
originalPassword: [
{ required: true, message: '原密码不能为空', trigger: 'blur' },
{ min: 8, message: '密码最低8位', trigger: 'blur' },
{ max: 32, message: '密码最长32位', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '新密码不能为空', trigger: 'blur' },
{ min: 8, message: '密码最低8位', trigger: 'blur' },
{ max: 32, message: '密码最长32位', trigger: 'blur' }
],
confirmPassword: [
{ validator: validatePasswordSame, message: '密码不一致', trigger: 'blur' }
]
}
//
const infoFormRules = {
avatar: [
{ required: true, message: '头像不能为空', trigger: 'blur' }
],
nickname: [
{ required: true, message: '昵称不能为空', trigger: 'blur' }
]
}
//
function validatePasswordSame(rule, value) {
return value === changePasswordFormModel.value.newPassword;
}
//
function handleSelect(key) {
switch (key) {
case 'logout':
$dialog.confirm({
title: '提示',
type: 'info',
content: '确认退出?',
confirm() {
userStore.logout()
$message.success('已退出登录')
},
})
break;
case 'change-password':
showChangePwdModel.value = true
break;
case 'info':
infoFormModel.value = useUserStore().localUserInfo
showAvatar.value = infoFormModel.value.avatar
showInfoModel.value = true
break;
}
}
//
async function changePasswordHandle() {
changePasswordFormRef.value.validate(async (errors) => {
if (errors) {
return errors
}
const res = await api.changePassword(changePasswordFormModel.value)
if (res.data.code === 200) {
changePasswordFormModel.value = ref(null)
showChangePwdModel.value = false
}
})
}
//
async function changeAvatar() {
const res = await api.generateAvatar()
if (res.data.code === 200) {
showAvatar.value = res.data.data
}
}
async function updateInfo() {
infoFormRef.value.validate(async (errors) => {
if (errors) {
return errors
}
if (showAvatar.value !== infoFormModel.value.avatar) {
infoFormModel.value.avatar = showAvatar.value
}
const res = await api.addOrUpdateUser(infoFormModel.value)
if (res.data.code === 200) {
infoFormModel.value = ref(null)
showInfoModel.value = false
await useUserStore().getUserInfo()
}
})
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<div flex items-center>
<MenuCollapse />
<BreadCrumb ml-15 hidden sm:block />
</div>
<div ml-auto flex items-center>
<FullScreen />
<UserAvatar />
</div>
</template>
<script setup>
import BreadCrumb from './components/BreadCrumb.vue'
import MenuCollapse from './components/MenuCollapse.vue'
import FullScreen from './components/FullScreen.vue'
import UserAvatar from './components/UserAvatar.vue'
</script>

View File

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

View File

@ -0,0 +1,120 @@
<template>
<n-menu
ref="menu"
class="side-menu"
accordion
:indent="18"
:collapsed-icon-size="22"
:collapsed-width="64"
:options="menuOptions"
:value="activeKey"
@update:value="handleMenuSelect"
/>
</template>
<script setup>
import { usePermissionStore } from '@/store'
import { renderCustomIcon, renderIcon, isExternal } from '@/utils'
const router = useRouter()
const curRoute = useRoute()
const permissionStore = usePermissionStore()
const activeKey = computed(() => curRoute.meta?.activeMenu || curRoute.name)
const menuOptions = computed(() => {
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
})
const menu = ref(null)
watch(curRoute, async () => {
await nextTick()
menu.value?.showOption()
})
function resolvePath(basePath, path) {
if (isExternal(path)) return path
return (
'/' +
[basePath, path]
.filter((path) => !!path && path !== '/')
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
.join('/')
)
}
function getMenuItem(route, basePath = '') {
let menuItem = {
label: (route.meta && route.meta.title) || route.name,
key: route.name,
path: resolvePath(basePath, route.path),
icon: getIcon(route.meta),
order: route.meta?.order || 0,
}
const visibleChildren = route.children
? route.children.filter((item) => item.name && !item.isHidden)
: []
if (!visibleChildren.length) return menuItem
if (visibleChildren.length === 1) {
//
const singleRoute = visibleChildren[0]
menuItem = {
...menuItem,
label: singleRoute.meta?.title || singleRoute.name,
key: singleRoute.name,
path: resolvePath(menuItem.path, singleRoute.path),
icon: getIcon(singleRoute.meta),
}
const visibleItems = singleRoute.children
? singleRoute.children.filter((item) => item.name && !item.isHidden)
: []
if (visibleItems.length === 1) {
menuItem = getMenuItem(visibleItems[0], menuItem.path)
} else if (visibleItems.length > 1) {
menuItem.children = visibleItems
.map((item) => getMenuItem(item, menuItem.path))
.sort((a, b) => a.order - b.order)
}
} else {
menuItem.children = visibleChildren
.map((item) => getMenuItem(item, menuItem.path))
.sort((a, b) => a.order - b.order)
}
return menuItem
}
function getIcon(meta) {
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
function handleMenuSelect(key, item) {
if (isExternal(item.path)) {
window.open(item.path)
} else {
router.push(item.path)
}
}
</script>
<style lang="scss">
.side-menu:not(.n-menu--collapsed) {
.n-menu-item-content {
&::before {
left: 5px;
right: 5px;
}
&.n-menu-item-content--selected,
&:hover {
&::before {
border-left: 4px solid var(--primary-color);
}
}
}
}
</style>

View File

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

View File

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

View File

@ -0,0 +1,106 @@
<template>
<ScrollX ref="scrollXRef" class="bg-white dark:bg-dark!">
<n-tag
v-for="tag in tagsStore.tags"
ref="tabRefs"
:key="tag.path"
class="mx-5 cursor-pointer rounded-4 px-15 hover:color-primary"
:type="tagsStore.activeTag === tag.path ? 'primary' : 'default'"
:closable="tagsStore.tags.length > 1"
@click="handleTagClick(tag.path)"
@close.stop="tagsStore.removeTag(tag.path)"
@contextmenu.prevent="handleContextMenu($event, tag)"
>
<template v-if="tag.icon" #icon>
<TheIcon :icon="tag.icon" class="mr-4" />
</template>
{{ tag.title }}
</n-tag>
<ContextMenu
v-if="contextMenuOption.show"
v-model:show="contextMenuOption.show"
:current-path="contextMenuOption.currentPath"
:x="contextMenuOption.x"
:y="contextMenuOption.y"
/>
</ScrollX>
</template>
<script setup>
import ContextMenu from './ContextMenu.vue'
import { useTagsStore } from '@/store'
import ScrollX from '@/components/common/ScrollX.vue'
const route = useRoute()
const router = useRouter()
const tagsStore = useTagsStore()
const tabRefs = ref([])
const scrollXRef = ref(null)
const contextMenuOption = reactive({
show: false,
x: 0,
y: 0,
currentPath: '',
})
watch(
() => route.path,
() => {
const { name, fullPath: path } = route
const title = route.meta?.title
const icon = route.meta?.icon
const keepAlive = route.meta?.keepAlive
tagsStore.addTag({ name, path, title, icon, keepAlive })
},
{ immediate: true }
)
watch(
() => tagsStore.activeIndex,
async (activeIndex) => {
await nextTick()
const activeTabElement = tabRefs.value[activeIndex]?.$el
if (!activeTabElement) return
const { offsetLeft: x, offsetWidth: width } = activeTabElement
scrollXRef.value?.handleScroll(x + width, width)
},
{ immediate: true }
)
const handleTagClick = (path) => {
tagsStore.setActiveTag(path)
router.push(path)
}
function showContextMenu() {
contextMenuOption.show = true
}
function hideContextMenu() {
contextMenuOption.show = false
}
function setContextMenu(x, y, currentPath) {
Object.assign(contextMenuOption, { x, y, currentPath })
}
//
async function handleContextMenu(e, tagItem) {
const { clientX, clientY } = e
hideContextMenu()
setContextMenu(clientX, clientY, tagItem.path)
await nextTick()
showContextMenu()
}
</script>
<style>
.n-tag__close {
box-sizing: content-box;
border-radius: 50%;
font-size: 12px;
padding: 2px;
transform: scale(0.9);
transform: translateX(5px);
transition: all 0.3s;
}
</style>

42
web/src/layout/index.vue Normal file
View File

@ -0,0 +1,42 @@
<template>
<n-layout has-sider wh-full>
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="200"
:native-scrollbar="false"
:collapsed="appStore.collapsed"
>
<SideBar />
</n-layout-sider>
<article flex-col flex-1 overflow-hidden>
<header
border-b="1 solid #eee"
class="flex items-center bg-white px-15"
dark="bg-dark border-0"
:style="`height: ${header.height}px`"
>
<AppHeader />
</header>
<section v-if="tags.visible" hidden border-b bc-eee sm:block dark:border-0>
<AppTags :style="{ height: `${tags.height}px` }" />
</section>
<section flex-1 overflow-hidden bg-hex-f5f6fb dark:bg-hex-101014>
<AppMain />
</section>
</article>
</n-layout>
</template>
<script setup>
import AppHeader from './components/header/index.vue'
import SideBar from './components/sidebar/index.vue'
import AppMain from './components/AppMain.vue'
import AppTags from './components/tags/index.vue'
import { useAppStore } from '@/store'
import { header, tags } from '~/settings'
const appStore = useAppStore()
</script>

21
web/src/main.js Normal file
View File

@ -0,0 +1,21 @@
/** 重置样式 */
import '@/styles/reset.css'
import 'uno.css'
import '@/styles/global.scss'
import 'virtual:svg-icons-register'
import { createApp } from 'vue'
import { setupRouter } from '@/router'
import { setupStore } from '@/store'
import App from './App.vue'
import { setupNaiveDiscreteApi } from './utils'
async function setupApp() {
const app = createApp(App)
setupStore(app)
await setupRouter(app)
app.mount('#app')
setupNaiveDiscreteApi()
}
setupApp()

View File

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

View File

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

View File

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

View File

@ -0,0 +1,19 @@
import { getToken, isNullOrWhitespace } from '@/utils'
const WHITE_LIST = ['/login', '/404']
export function createPermissionGuard(router) {
router.beforeEach(async (to) => {
const token = getToken()
/** 没有token的情况 */
if (isNullOrWhitespace(token)) {
if (WHITE_LIST.includes(to.path)) return true
return { path: 'login', query: { ...to.query, redirect: to.path } }
}
/** 有token的情况 */
if (to.path === '/login') return { path: '/' }
return true
})
}

67
web/src/router/index.js Normal file
View File

@ -0,0 +1,67 @@
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import { setupRouterGuard } from './guard'
import { basicRoutes, EMPTY_ROUTE, NOT_FOUND_ROUTE } from './routes'
import { getToken, isNullOrWhitespace } from '@/utils'
import { useUserStore, usePermissionStore } from '@/store'
const isHash = import.meta.env.VITE_USE_HASH === 'true'
export const router = createRouter({
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
routes: basicRoutes,
scrollBehavior: () => ({ left: 0, top: 0 }),
})
export async function setupRouter(app) {
await addDynamicRoutes()
setupRouterGuard(router)
app.use(router)
}
export async function resetRouter() {
const basicRouteNames = getRouteNames(basicRoutes)
router.getRoutes().forEach((route) => {
const name = route.name
if (!basicRouteNames.includes(name)) {
router.removeRoute(name)
}
})
}
export async function addDynamicRoutes() {
const token = getToken()
// 没有token情况
if (isNullOrWhitespace(token)) {
router.addRoute(EMPTY_ROUTE)
return
}
// 有token的情况
const userStore = useUserStore()
try {
const permissionStore = usePermissionStore()
!userStore.id && (await userStore.getUserInfo())
const accessRoutes = permissionStore.generateRoutes(userStore.role)
accessRoutes.forEach((route) => {
!router.hasRoute(route.name) && router.addRoute(route)
})
router.hasRoute(EMPTY_ROUTE.name) && router.removeRoute(EMPTY_ROUTE.name)
router.addRoute(NOT_FOUND_ROUTE)
} catch (error) {
console.error(error)
$message.error('初始化用户信息失败: ' + error)
userStore.logout()
}
}
export function getRouteNames(routes) {
return routes.map((route) => getRouteName(route)).flat(1)
}
function getRouteName(route) {
const names = [route.name]
if (route.children && route.children.length) {
names.push(...route.children.map((item) => getRouteName(item)).flat(1))
}
return names
}

View File

@ -0,0 +1,34 @@
const Layout = () => import('@/layout/index.vue')
export const basicRoutes = [
{
name: 'Login',
path: '/login',
component: () => import('@/views/login/index.vue'),
isHidden: true,
meta: {
title: '登录页',
},
}
]
export const NOT_FOUND_ROUTE = {
name: 'NotFound',
path: '/:pathMatch(.*)*',
redirect: '/404',
isHidden: true,
}
export const EMPTY_ROUTE = {
name: 'Empty',
path: '/:pathMatch(.*)*',
component: null,
}
const modules = import.meta.glob('@/views/**/route.js', { eager: true })
const asyncRoutes = []
Object.keys(modules).forEach((key) => {
asyncRoutes.push(modules[key].default)
})
export { asyncRoutes }

7
web/src/store/index.js Normal file
View File

@ -0,0 +1,7 @@
import { createPinia } from 'pinia'
export function setupStore(app) {
app.use(createPinia())
}
export * from './modules'

View File

@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { useDark } from '@vueuse/core'
const isDark = useDark()
export const useAppStore = defineStore('app', {
state() {
return {
collapsed: false,
isDark,
}
},
actions: {
switchCollapsed() {
this.collapsed = !this.collapsed
},
setCollapsed(collapsed) {
this.collapsed = collapsed
},
/** 设置暗黑模式 */
setDark(isDark) {
this.isDark = isDark
},
/** 切换/关闭 暗黑模式 */
toggleDark() {
this.isDark = !this.isDark
},
},
})

View File

@ -0,0 +1,4 @@
export * from './app'
export * from './permission'
export * from './tags'
export * from './user'

View File

@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { asyncRoutes, basicRoutes } from '@/router/routes'
function hasPermission(route, role) {
// * 不需要权限直接返回true
if (!route.meta?.requireAuth) return true
const routeRole = route.meta?.role ? route.meta.role : []
// * 登录用户没有角色或者路由没有设置角色判定为没有权限
if (!role.length || !routeRole.length) return false
// * 路由指定的角色包含任一登录用户角色则判定有权限
return role.some((item) => routeRole.includes(item))
}
function filterAsyncRoutes(routes = [], role) {
const ret = []
routes.forEach((route) => {
if (hasPermission(route, role)) {
const curRoute = {
...route,
children: [],
}
if (route.children && route.children.length) {
curRoute.children = filterAsyncRoutes(route.children, role)
} else {
Reflect.deleteProperty(curRoute, 'children')
}
ret.push(curRoute)
}
})
return ret
}
export const usePermissionStore = defineStore('permission', {
state() {
return {
accessRoutes: [],
}
},
getters: {
routes() {
return basicRoutes.concat(this.accessRoutes)
},
menus() {
return this.routes.filter((route) => route.name && !route.isHidden)
},
},
actions: {
generateRoutes(role = []) {
const accessRoutes = filterAsyncRoutes(asyncRoutes, role)
this.accessRoutes = accessRoutes
return accessRoutes
},
resetPermission() {
this.$reset()
},
},
})

View File

@ -0,0 +1,6 @@
import { sStorage } from '@/utils'
export const activeTag = sStorage.get('activeTag')
export const tags = sStorage.get('tags')
export const WITHOUT_TAG_PATHS = ['/404', '/login']

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