upload for gitea

This commit is contained in:
jqtmviyu 2025-03-29 11:46:12 +08:00
commit f863fd2740
87 changed files with 62975 additions and 0 deletions

3
.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

27
.cz-config.js Normal file
View File

@ -0,0 +1,27 @@
module.exports = {
types: [
{ value: 'feat', name: 'feat: 增加新功能' },
{ value: 'fix', name: 'fix: 修复bug' },
{ value: 'docs', name: 'docs: 修改文档' },
{ value: 'style', name: 'style: 样式修改不影响逻辑' },
{ value: 'refactor', name: 'refactor: 代码重构' },
{ value: 'perf', name: 'perf: 性能优化' },
{ value: 'test', name: 'test: 增删测试' },
{ value: 'build', name: 'build: 修改构建或外部依赖' },
{ value: 'ci', name: 'ci: 修改ci配置或脚本' },
{ value: 'chore', name: 'chore: 除src目录或测试文件以外的修改' },
{ value: 'revert', name: 'revert: 版本回退' },
],
scopes: [],
messages: {
type: '选择更改类型:\n',
customScope: '更改的范围:\n', // ( 'Denote the SCOPE of this change:') allowcustomscopes为truekey为:customScope, 否则为scope
subject: '简短描述:\n',
body: '详细描述. 使用"|"换行:\n',
breaking: 'Breaking Changes列表:\n',
footer: '关闭的issues列表. E.g.: #31, #34:\n',
confirmCommit: '确认提交?',
},
allowCustomScopes: true,
allowBreakingChanges: ['feat', 'fix'],
}

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
NODE_ENV=development
VUE_APP_API_BASE_URL=/proxy

2
.env.production Normal file
View File

@ -0,0 +1,2 @@
NODE_ENV=production
VUE_APP_API_BASE_URL=''

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist/
public/
build/

95
.eslintrc.js Normal file
View File

@ -0,0 +1,95 @@
module.exports = {
root: true, // 根配置文件
parserOptions: {
// 语法解析器选项
parser: 'babel-eslint', // es6, 兼容箭头函数等
sourceType: 'module',
},
env: {
// 执行环境
browser: true,
node: true,
es6: true,
},
extends: ['plugin:vue/essential', 'eslint:recommended', 'prettier'], // 基础配置进行扩展
plugins: ['prettier'],
rules: {
// 校验规则
'vue/component-definition-name-casing': [2, 'PascalCase'], // vue组件name, PascalCase为大驼峰
'vue/require-prop-types': 2, // prop必须声明类型
'vue/v-bind-style': [1, 'shorthand'], // 动态绑定用缩写:
'vue/v-on-style': [1, 'shorthand'], // 事件绑定用缩写@
'vue/component-tags-order': [
2,
{
order: ['template', 'script', 'style'],
},
], // 模板顺序
// todo 根据env环境设置是否允许console和debugger
'no-console': process.env.NODE_ENV === 'production' ? 1 : 0, // 禁用 console
'no-debugger': process.env.NODE_ENV === 'production' ? 1 : 0, // 禁用 debugger
'require-await': 2, // 禁止使用不带 await 表达式的 async 函数
'constructor-super': 2, // 要求在构造函数中有 super() 的调用
'handle-callback-err': [2, '^(err|error)$'], // 要求回调函数中有容错处理
'new-cap': [
2,
{
newIsCap: true,
capIsNew: false,
},
], // 要求构造函数首字母大写
'no-caller': 2, // 禁用 arguments.caller 或 arguments.callee
'no-eval': 2, // 禁用 eval()
'no-extend-native': 2, // 禁止扩展原生类型
'no-extra-bind': 2, // 禁止不必要的 .bind() 调用
'no-extra-parens': [2, 'functions'], // 禁止不必要的括号
'no-floating-decimal': 2, // 禁止数字字面量中使用前导和末尾小数点
'no-implied-eval': 2, // 禁止使用类似 eval() 的方法
'no-iterator': 2, // 禁用 __iterator__ 属性
'no-label-var': 2, // 不允许标签与变量同名
'no-labels': [
2,
{
allowLoop: false,
allowSwitch: false,
},
], // 禁用标签语句
'no-lone-blocks': 2, // 禁用不必要的嵌套块
'no-multi-str': 2, // 禁止使用多行字符串
'no-array-constructor': 2, // 禁用 Array 构造函数
'no-new-object': 2, // 禁止使用new Object()
'no-new-require': 2, // 禁止调用 require 时使用 new 操作符
'no-new-wrappers': 2, // 禁止对 StringNumber 和 Boolean 使用 new 操作符
'no-octal-escape': 2, // 禁止在字符串中使用八进制转义序列
'no-path-concat': 2, // 禁止对 __dirname 和 __filename 进行字符串连接
'no-proto': 2, // 禁用__proto__, 在 ECMAScript 3.1 中已经被弃用, 使用 Object.getPrototypeOf 和 Object.setPrototypeOf 代替
'no-return-assign': [2, 'except-parens'], // 禁止在 return 语句中使用赋值语句
'no-self-compare': 2, // 禁止自身比较
'no-sequences': 2, // 禁用逗号操作符
'no-throw-literal': 2, // 禁止抛出异常字面量
'no-unmodified-loop-condition': 2, // 禁用一成不变的循环条件
'no-unneeded-ternary': [
2,
{
defaultAssignment: false,
},
], // 禁止可以在有更简单的可替代的表达式时使用三元操作符
'no-useless-call': 2, // 禁止不必要的 .call() 和 .apply()
'no-useless-computed-key': 2, // 禁止在对象中使用不必要的计算属性
'no-useless-constructor': 2, // 禁用不必要的构造函数
'no-useless-escape': 0, // 禁用不必要的转义字符
'no-whitespace-before-property': 2, // 禁止属性前有空白
'operator-linebreak': [
2,
'after',
{
overrides: {
'?': 'before',
':': 'before',
},
},
], // 强制操作符使用一致的换行符风格,
'spaced-comment': [2, 'always'], // 强制在注释中 // 或 /* 使用一致的空格
},
}

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
!.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
.prettierignore Executable file
View File

@ -0,0 +1,8 @@
node_modules/
package.json
package-lock.json
*.md
dist/
build/
*.vue
color.less

27
.prettierrc.js Executable file
View File

@ -0,0 +1,27 @@
module.exports = {
printWidth: 100, // 设置prettier单行输出不折行最大长度
proseWrap: 'preserve', // 使用默认的折行标准
tabWidth: 2, // 设置工具每一个水平缩进的空格数
endOfLine: 'auto', // 文件换行格式 LF/CRLF
useTabs: false, // 使用tab制表位缩进而非空格
semi: false, // 在语句末尾添加分号
singleQuote: true, // 使用单引号代替双引号
quoteProps: 'as-needed', // 对象的 key 仅在必要时用引号
trailingComma: 'es5', // 在对象或数组最后一个元素后面是否加逗号
bracketSpacing: true, // 在对象字面量声明所使用的的花括号后({)和前(})输出空格
arrowParens: 'avoid', // 为单行箭头函数的参数添加圆括号参数个数为1时可以省略圆括号
// parser: 'babylon', // Prettier 会自动从输入文件路径推断解析器
jsxBracketSameLine: true, // 在多行JSX元素最后一行的末尾添加 > 而使 > 单独一行(不适用于自闭和元素)
filepath: 'none', // 指定文件的输入路径,这将被用于解析器参照
requirePragma: false, // (v1.7.0+) Prettier可以严格按照按照文件顶部的一些特殊的注释格式化代码这些注释称为“require pragma”(必须杂注)
insertPragma: false, // (v1.8.0+) Prettier可以在文件的顶部插入一个 @format的特殊注释以表明改文件已经被Prettier格式化过了。
// 配置覆盖
overrides: [
{
files: ['*.json', '*.css', '*.less', '*.sass'],
options: {
singleQuote: false, // 使用单引号代替双引号
},
},
],
}

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

@ -0,0 +1,8 @@
{
"recommendations": [
"octref.vetur",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ant-design-vue.vscode-ant-design-vue-helper"
]
}

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

@ -0,0 +1,69 @@
{
/* html */
"emmet.triggerExpansionOnTab": true, // TABEmmet
"emmet.syntaxProfiles": {
"vue-html": "html",
"vue": "html"
},
"emmet.includeLanguages": {
"vue-html": "html",
"vue": "html"
},
/* */
"editor.formatOnSave": true, //
"editor.formatOnSaveMode": "file", // "editor.formatOnSave" true
"editor.formatOnType": true, //
/* */
"search.followSymlinks": false, // VSCodenode_modules
/* */
"files.exclude": {
"**/node_modules/": true,
"dist": true
},
/* vetur */
// "vetur.validation.template": false, // 使esLint-Plugin-vuetemplate
"vetur.format.defaultFormatter.html": "prettier",
"vetur.format.defaultFormatter.js": "prettier",
"vetur.format.defaultFormatter.css": "prettier",
"vetur.format.defaultFormatter.less": "prettier",
"vetur.format.defaultFormatter.scss": "prettier",
"vetur.format.defaultFormatter.stylus": "stylus-supremacy",
"vetur.format.defaultFormatter.sass": "sass-formatter",
"vetur.format.defaultFormatter.postcss": "prettier",
"vetur.format.defaultFormatter.ts": "prettier",
/* eslint */
"eslint.alwaysShowStatus": true, // ESlinttrue
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.codeAction.showDocumentation": {
"enable": true
},
"eslint.run": "onType",
"eslint.options": {
"extensions": [".js", ".vue", ".jsx", ".tsx"]
},
/* // */
"[vue]": {
// *.vue vetur
"editor.defaultFormatter": "octref.vetur"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[less]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

732
README.md Normal file
View File

@ -0,0 +1,732 @@
[TOC]
# 前端通用框架
## 框架介绍
基于`vue@2.6`, `ant-design-vue@1.7`, `axios`, `vue-router`, `vuex`, `webpack@4.x` 的通用后台管理系统
功能:
1. 根据路由动态生成侧边导航
2. 使用`mock.js``mock-serve`进行本地mock
3. 集成`eslint``prettier`配置
4. 集成`commit插件`
5. 集成`git`钩子, 必须通过`eslit`代码检查和`commit`提交规范检查才能提交代码
6. `ant-design-vue` 组件按需引入, ant-design icon 按需引入
7. 响应错误统一处理
8. `vue-ls`本地持久化
9. 支持设置主题
10. 权限自定义指令
## not Support
1. ie11 兼容
2. h5 兼容
3. 响应式
## 快速开始
推荐使用vscode开发, 需要安装`prettier`, `eslint`, `vetur`插件
```bash
# 需要全局安装commitizen才能使用 git cz
npm install commitizen -g
# 克隆项目
git clone xxx
git cd xxx
# 安装插件
yarn
# 启动服务
yarn run dev
# 打包
yarn run build
```
## 目录结构
```json
├─.browserslistrc ------------------------ // 目标浏览器配置
├─.env.development ----------------------- // 开发环境变量配置
├─.env.production ------------------------ // 开发环境变量配置
├─.eslintignore -------------------------- // eslint忽略文件
├─.eslintrc.js --------------------------- // eslisnt配置
├─.gitignore ----------------------------- // git忽略配置
├─.prettierignore ------------------------ // prettier忽略配置
├─.prettierrc.js ------------------------- // 代码风格配置
├─.vscode -------------------------------- // vscode项目设置
└─settings.json ------------------------ // vscode配置
├─README.md
├─babel.config.js ------------------------ // js编译器设置
├─commitlint.config.js ------------------- // git commit lint
├─jsconfig.json -------------------------- // JavaScript项目的根目录配置
├─package.json --------------------------- // npm包管理
├─public --------------------------------- // 静态资源文件夹
├─color.less --------------------------- // 主题颜色配置
├─favicon.ico -------------------------- // 网站图标
└─index.html --------------------------- // 主页
├─src ------------------------------------ // 代码目录
├─App.vue ------------------------------ // 项目根组件
├─api ---------------------------------- // api目录
└─user.js ---------------------------- // 用户相关接口
├─assets ------------------------------- // 资源文件夹
├─avatar.jpg ------------------------- // 默认头像
├─background.svg --------------------- // 登录页背景
├─checkcode.png ---------------------- // 验证码默认图
├─icons ------------------------------ // 图标相关
├─index.js
├─svg ------------------------------ // svg图标存放路径
├─develop.svg
└─svgtest.svg
└─svgo.yml ------------------------- // svg图标格式化配置
├─less ------------------------------- // 样式
└─common.less ---------------------- // 公共样式
└─logo.png
├─components --------------------------- // 公共组件
├─Header.vue ------------------------- // 头部
├─SettingDrawer ---------------------- // 系统设置(主题)
├─index.vue
└─settingConfig.js ----------------- // 主题颜色生成
├─SvgIcon.vue ------------------------ // svg图标组件
├─TopTabs.vue ------------------------ // tab栏导航
├─UserPassword.vue
├─layouts ---------------------------- // 布局相关
├─AggregateLayout(可以删除).vue
├─BlankLayout.vue ------------------ // 空白布局
├─GlobalLayout.vue ----------------- // 全局部局
├─SideTabsLayout.vue --------------- // 侧边切换tab布局
└─UserLayout.vue ------------------- // 登录/注册/找回密码共用布局
└─menu
│ │  ├─Contextmenu.vue ------------------ // 上下文菜单(在顶部导航鼠标右键菜单用到)
│ │  └─SideMenu.js ---------------------- // 左侧导航栏生成
├─config
└─defaultSettings.js ----------------- // 全局主题颜色/vue-ls配置
├─main.js ------------------------------ // 入口
├─mock --------------------------------- // mock
├─constant --------------------------- // 存放较大的数据
└─userPermission.js ---------------- // 权限模拟数据
├─index.js
├─mock-server.js --------------------- // mock-server本地服务
├─table.js --------------------------- // mock语法示例
├─user.js ---------------------------- // 模拟接口
└─utils.js --------------------------- // mock服务工具方法
├─router ------------------------------- // 路由
├─generateIndexRouter.js ------------- // 动态路由生成
└─index.js
├─store -------------------------------- // vuex
├─index.js
└─modules ---------------------------- // vuex模块
│ │  ├─app.js --------------------------- // 全局
│ │  └─user.js -------------------------- // 用户相关
├─utils -------------------------------- // 工具类
├─lazy ------------------------------- // 按需加载
├─icons.js ------------------------- // 图标
└─lazyAntd.js ---------------------- // ant-design-vue组件
└─request.js ------------------------- // axios封装
└─views -------------------------------- // 页面
│  ├─About.vue -------------------------- // 关于
│  ├─Home.vue --------------------------- // 首页
│  ├─account ---------------------------- // 账号相关
│  │├─Center.vue ----------------------- // 个人中心页
│  │└─settings ------------------------- // 个人设置页
│  │  ├─BaseSetting.vue
│  │  ├─Binding.vue
│  │  ├─Custom.vue
│  │  ├─Notification.vue
│  │  └─Security.vue
│  ├─exception -------------------------- // 404/403/500等错误页
│  │└─404.vue
│  ├─list ------------------------------- // 多级菜单 示例
│  │├─StandardList.vue
│  │└─TableList.vue
│  ├─modules ---------------------------- // 存放页面公共组件
│  │└─online
│  │  └─desform ------------------------ // 列表页跳详情页 示例
│  │  ├─DesignFormList.vue
│  │  └─DesignFormTempletList.vue
│  ├─oneLevelMenu ----------------------- // 一级菜单示例页面
│  │└─index.vue
│  ├─profile ---------------------------- // 路由聚合示例
│  │├─Advanced.vue
│  │└─Basic.vue
│  ├─sideTabs --------------------------- // 侧边栏tab标签 示例
│  │├─components
│  │├─Tab1.vue
│  │├─Tab2.vue
│  │├─Tab3.vue
│  │└─Tab4.vue
│  │└─index.vue
│  ├─title
│  │└─index.vue
│  └─user ------------------------------- // 登录/注册/找回密码 相关
│  ├─Login.vue ------------------------ // 登录页
│  └─LoginSimple.vue
├─test ----------------------------------- // 测试用例
└─test.js
├─vue.config.js -------------------------- // vue-cli配置
└─yarn.lock ------------------------------ // npm包版本控制
```
### vue组件的存放路径:
1. 框架相关的组件, 存放在`@/components`
2. 布局相关的, 存在在`@/components/layouts`
3. 不相关的两个页面的公共组件, 存放在`@/viewmodules`
4. 父子关系/兄第关系的页面的公共组件, 且没有其他页面使用, 存放在他们的公共根目录下`@/views/xxx/components`
## 动态路由
### 路由菜单不要超过3层
<img src="https://i.loli.net/2021/09/18/opbsBDxKWn2vMId.png" alt="image.png" style="zoom:50%;" />
### 路由生成
<img src="https://i.loli.net/2021/09/18/1XJzZxdUqrluGDN.png" alt="image.png" style="zoom:50%;" />
### 路由配置项:
```json
{
// "hidden": true, // (默认不传)不在左侧菜单中显示
// "redirect":null, // (默认不传)重定向
// "alwaysShow": false, // (默认不传)路由聚合, 从menu菜单展示变为tab栏展示
"path":"/dashboard/analysis", // 路径
"component":"dashboard/Analysis", // 组件
"name":"dashboard-analysis", // 路由名称, 使用<keep-alive>时必填
"meta":{
"keepAlive":true, // 是否缓存
"icon":"home", // 图标
"title":"首页", // 侧边栏和面包屑中展示的名字
// "url": "http://www.baidu.com" // (默认不传)当外部链接跳转时必须传, 必须带http:// 或者httPs://
},
},
```
举例:
"系统管理"为一级菜单
"用户管理"为子菜单
```json
{
"path":"/isystem",
"component":"layouts/RouteView",
"name":"isystem",
"meta":{
"keepAlive":false,
"icon":"setting",
"title":"系统管理"
},
"children":[
{
"path":"/isystem/user",
"component":"system/UserList",
"name":"isystem-user",
"meta":{
"keepAlive":false,
"title":"用户管理"
},
},
{
"path":"/isystem/roleUserList",
"component":"system/RoleUserList",
"name":"isystem-roleUserList",
"meta":{
"keepAlive":true,
"title":"角色管理"
},
},
{
"path":"/isystem/newPermissionList",
"component":"system/NewPermissionList",
"name":"isystem-newPermissionList",
"meta":{
"keepAlive":true,
"title":"菜单管理"
}
}
}
```
### 路由聚合
<img src="https://i.loli.net/2021/09/18/zZns3Lr89ClJ2iD.png" alt="image.png" style="zoom:50%;" />
1. children中的路由不会在左侧菜单栏中显示
2. children中的路由只能有一层
3. children中的路由所对应的组件, 聚合到tab栏组件中, 通过tab栏切换
4. redirect无效, tab栏顺序为children顺序, 默认展示第一个
```json
{
path: '/account/settings',
redirect: '/account/settings/notification',
component: 'layouts/BlankLayout',
alwaysShow: true,
meta: {
title: '个人设置',
},
name: 'account-settings-Index',
children: [
{
path: '/account/settings/notification',
component: 'account/settings/Notification',
meta: {
title: '新消息通知',
},
name: 'account-settings-notification',
},
{
path: '/account/settings/base-setting',
component: 'account/settings/BaseSetting',
meta: {
title: '基本设置',
},
name: 'account-settings-base',
},
{
path: '/account/settings/binding',
component: 'account/settings/Binding',
meta: {
title: '账户绑定',
},
name: 'account-settings-binding',
},
{
path: '/account/settings/security',
component: 'account/settings/Security',
meta: {
title: '安全设置',
},
name: 'account-settings-security',
},
{
path: '/account/settings/custom',
component: 'account/settings/Custom',
meta: {
title: '个性化设置',
},
name: 'account-settings-custom',
},
],
}
```
### 列表页和详情页
1. 简单的详情页, 直接通过`抽屉`/ `模态框`渲染
2. 复杂的详情页, 通过`/id`跳转到新页面
<img src="https://i.loli.net/2021/09/18/FL21JyDI5mWwh3s.png" alt="image.png" style="zoom:50%;" />
<img src="https://i.loli.net/2021/09/18/HgJaSM5ik9vwFCR.png" alt="image.png" style="zoom:50%;" />
```json
// 路由结构 举例
{
"path": "/test",
// 加载默认子路由, 不能有name
"component":"layouts/BlankLayout",
"meta": {
"title":"测试页面",
redirectDefaultChild: true, // 子路由不在菜单栏中显示, 进入子路由显示父路由高亮
},
"children":[
{
"path": "", // 默认子路由
"name": "test-list",
"component":"test/List",
"meta": {
"title":"测试页面列表页",
// "keepAlive": true // 可以设置keepAlive使详情页回到列表页, 不重新渲染组件
},
},
{
"path": "/test/:id", // 详情页
"name": "test-detail",
"component":"test/Detail",
"meta": {
"title":"测试页面详情页"
},
}
]
}
```
### 外链url跳转
<img src="https://i.loli.net/2021/09/18/efP12VRlLObi36v.png" alt="image.png" style="zoom:50%;" />
```json
{
path: '/urllink',
component: 'layouts/BlankLayout',
name: 'baidu',
meta: {
title: 'url跳转',
url: 'https://www.baidu.com', // url必须完整, 带http://或者https://
},
}
```
## 动态权限
待补充完善
## mock
### (本地)开发模式
1. 启动`yarn run dev`时, 会同时启动`mock-serve`, 与mock相关的接口会转发到`mock-serve`
2. mock文件示例
```js
// /mock/user.js
const tokens = {
admin: {
token: 'admin-token',
},
editor: {
token: 'editor-token',
},
}
module.exports = [
// 登录接口
{
url: '/user/login',
type: 'post',
response: config => {
const { username } = config.body
const token = tokens[username]
// mock error
if (!token) {
return {
errcode: 1,
errmsg: 'Account and password are incorrect.',
}
}
return {
errcode: 0,
data: token,
}
},
},
// 其他接口...
]
```
```js
// /mock/index.js
// ...
const user = require('./user')
const mocks = [
...user,
]
// ...
```
### (本地)生产模式
生产模式只能通过`mock.js`模拟数据, 与`mock-serve`共同使用同一份模拟数据
相关配置:
```js
// /src/main.js
if (process.env.NODE_ENV === 'production') {
const { mockXHR } = require('./mock')
mockXHR()
}
```
请求路径与mock.js中匹配的会被拦截, 但因为是重写了xhr方法, 无法在浏览器的network中看到请求, 可以通过控制台打印
==所以上线前需要在`/mock/index.js`把后端已完成的相关接口注释掉, 或者直接在`src/main.js`注释掉mock功能==
### 数据切换
1. 本地开发模式:
修改`vue.config.js`中的`mockUrls`
2. 本地生产模式
注释掉`/mock/index.js`中的对应接口
```js
const mocks = [
...user,
// ...table
]
```
全部取消:
```js
// src/main.js
// 注释掉这部分
// if (process.env.NODE_ENV === 'production') {
// const { mockXHR } = require('../mock')
// mockXHR()
// }
```
## ui组件, icon按需引入
1. ui组件在`src/utils/lazy/lazyAntd.js`中按需引入
2. ant-design-icon在`src/utils/lazy/icons.js`中按需引入
3. 其他icon:
1. 使用`Iconfont(阿里巴巴图标库)`下载svg图标, 统一放在`@/assets/icon/svg中`
2. 执行`yarn run svgo`去掉多余信息格式化图标
3. 使用`<svg-icon icon-class="xxx" />`调用
```html
<svg-icon style="font-size: 18px; color: pink" icon-class="svgtest" />
```
## 公共js , css, 工具js
* 公共js, css: 框架里只保留最小使用
* 工具js: 通用性高的工具js和框架一起维护, 需要保持工具js的单一性, 完善注释
## 体积较大的第三方js插件
例如 "excel"的导出插件, 统一放在`/src/vender`
## 泛用组件
例如"季度选择组件", 存放到gitlab公共组件仓库中
todo:
- [ ] 打包成npm包
- [ ] git submodules
## 开发规范
### 数据处理
* 后端接口获取的数据, 只取页面需要的, 不能直接存到data中
* 当某个接口的数据有多个页面使用时, 考虑存放到vuex/localstorage中
* 退出登录时, 需要考虑清空localStorage/sessionStorage中的敏感数据, 例如用户的登录信息, 需要更新的信息
### 列表页跳详情页
优先使用抽屉, 模态框, 无法满足需求时才考虑跳转新页面
### vue单文件的可维护性
建议不超过500行, 超过的需要考虑是否可以优化
> 1.抽离组件再引入
> 2.将css通过@import方式抽离再引入
> 3.将css/js/html全部抽离只保留模块主入口文件再引入
### ui组件
优先使用ant-design-vue, 按需引用
### icon
优先使用ant-design icon , 按需引用
### ui风格
1. 滚动条/滚动范围:
以表格为例: 横向溢出时, 内容加滚动条
纵向溢出, 随页面滚动, 表头页脚不固定(优先考虑分页)
### 组件通讯
* 父 -> 子 : props
```js
//App.vue父组件
<template>
<div id="app">
<users :users="users"></users> <!-- 前者自定义名称便于子组件调用,后者要传递数据名 -->
</div>
</template>
<script>
import Users from "./components/Users"
export default {
name: 'App',
data(){
return{
users:["Henry","Bucky","Emily"]
}
},
components:{
"users":Users
}
}
//users子组件
<template>
<div class="hello">
<ul>
<li v-for="user in users">{{user}}</li> <!-- 遍历传递过来的值,然后呈现到页面 -->
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props:{
users:{ //这个就是父组件中子标签自定义名字
type:Array,
required:true
}
}
}
</script>
```
* 子 -> 父 : $emit
```js
// 子组件
<template>
<header>
<h1 @click="changeTitle">{{title}}</h1> <!-- 绑定一个点击事件 -->
</header>
</template>
<script>
export default {
name: 'app-header',
data() {
return {
title:"Vue.js Demo"
}
},
methods:{
changeTitle() {
this.$emit("titleChanged","子向父组件传值");//自定义事件 传递值“子向父组件传值”
}
}
}
</script>
// 父组件
<template>
<div id="app">
<app-header v-on:titleChanged="updateTitle" ></app-header>
<!-- 与子组件titleChanged自定义事件保持一致 -->
<!-- updateTitle($event)接受传递过来的文字 -->
<h2>{{title}}</h2>
</div>
</template>
<script>
import Header from "./components/Header"
export default {
name: 'App',
data(){
return{
title:"传递的是一个值"
}
},
methods:{
updateTitle(e){ //声明这个函数
this.title = e;
}
},
components:{
"app-header":Header,
}
}
</script>
```
* 兄弟1 -> 兄弟2 :
* 如果兄弟组件有共同的父组件(子 - 父 - 子), 通过父组件传递
* 如果链路传递超过2层, 优先考虑vuex模块( 孙 - 子 - 父 - 子)
```js
// 参考 子 -> 父 父 -> 子
```
* 链路传递超过2层, 优先考虑vuex模块
* 避免使用事件总线(待补充)
* 使用插槽向引用组件传递模板
* 普通插槽
```js
// 父组件
<MyButton>注册</MyButton>
// 自定义组件
<button>
<slot>按钮</slot>
</button>
```
* 作用域插槽
```js
<current-user>
<template v-slot:default="slotProps">
{{ slotProps.user.firstName }}
</template>
<template v-slot:other="otherSlotProps">
...
</template>
</current-user>
```
## 数据持久化
### localStorage
**在vuex里数据改变的时候把数据拷贝一份保存到localStorage里面刷新之后如果localStorage里有保存的数据取出来再替换store里的state。**
```js
let defaultCity = "上海"
try { // 用户关闭了本地存储功能此时在外层加个try...catch
if (!defaultCity){
defaultCity = JSON.parse(window.localStorage.getItem('defaultCity'))
}
}catch(e){}
export default new Vuex.Store({
state: {
city: defaultCity
},
mutations: {
changeCity(state, city) {
state.city = city
try {
window.localStorage.setItem('defaultCity', JSON.stringify(state.city));
// 数据改变的时候把数据拷贝一份保存到localStorage里面
} catch (e) {}
}
}
})
```
### sessionStorage
参考`localStorage`

8
babel.config.js Normal file
View File

@ -0,0 +1,8 @@
// module.exports = {
// presets: ["@vue/cli-plugin-babel/preset"]
// };
module.exports = {
presets: ['@vue/app'],
plugins: [['import', { libraryName: 'ant-design-vue', libraryDirectory: 'es', style: true }]],
}

1
commitlint.config.js Normal file
View File

@ -0,0 +1 @@
module.exports = { extends: ['@commitlint/config-conventional'] }

9
jsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,291 @@
const mockRoutesResource = [
{
path: '/home',
component: 'Home',
name: 'home',
meta: {
icon: 'home',
title: '首页',
keepAlive: true,
},
},
{
path: '/sideTabs',
component: 'sideTabs/index',
name: 'sideTabs',
meta: {
icon: 'github',
title: '侧边栏tab标签',
keepAlive: true,
},
},
{
path: '/oneLevelMenu',
name: 'oneLevelMenu',
component: 'oneLevelMenu/index',
meta: {
icon: 'table',
title: '一级菜单页面',
},
},
{
path: '/list',
component: 'layouts/BlankLayout',
name: 'list',
meta: {
icon: 'table',
title: '多级菜单',
},
children: [
{
path: '/list/basic-list',
component: 'list/StandardList',
meta: {
title: '二级菜单页',
},
name: 'list-basic-list',
},
{
path: '/list/search',
component: 'layouts/BlankLayout',
children: [
{
path: '/list/search/application',
component: 'list/TableList',
meta: {
title: '三级菜单页',
},
name: 'list-search-application',
},
],
meta: {
title: '二级菜单',
},
name: 'list-search',
},
],
},
{
hidden: true,
path: '/account',
component: 'layouts/BlankLayout',
name: 'account',
meta: {
icon: 'user',
title: '个人页',
},
children: [
{
path: '/account/center',
component: 'account/Center',
name: 'account-center',
meta: {
title: '个人中心',
},
},
{
alwaysShow: true,
path: '/account/settings',
redirect: '/account/settings/notification',
component: 'layouts/BlankLayout',
name: 'account-settings-Index',
meta: {
title: '个人设置',
},
children: [
{
path: '/account/settings/notification',
component: 'account/settings/Notification',
name: 'account-settings-notification',
meta: {
title: '新消息通知',
},
},
{
path: '/account/settings/base-setting',
component: 'account/settings/BaseSetting',
name: 'account-settings-base',
meta: {
title: '基本设置',
},
},
{
path: '/account/settings/binding',
component: 'account/settings/Binding',
name: 'account-settings-binding',
meta: {
title: '账户绑定',
},
},
{
path: '/account/settings/security',
component: 'account/settings/Security',
name: 'account-settings-security',
meta: {
title: '安全设置',
},
},
{
path: '/account/settings/custom',
component: 'account/settings/Custom',
name: 'account-settings-custom',
meta: {
title: '个性化设置',
},
},
],
},
],
},
{
alwaysShow: true,
path: '/profile',
component: 'layouts/BlankLayout',
name: 'profile',
meta: {
icon: 'profile',
title: '路由聚合',
// keepAlive: true,
},
children: [
{
path: '/profile/basic',
component: 'profile/Basic',
name: 'profile-basic',
meta: {
title: '子路由1',
},
},
{
path: '/profile/advanced',
component: 'profile/Advanced',
name: 'profile-advanced',
meta: {
title: '子路由2',
},
},
],
},
{
path: '/desform',
component: 'layouts/BlankLayout',
meta: {
icon: 'gold',
title: '列表页跳详情页',
redirectDefaultChild: true,
},
children: [
{
path: '',
component: 'modules/online/desform/DesignFormList',
name: 'online-desform',
meta: {
keepAlive: true,
title: '列表页',
},
},
{
path: '/desform/:id',
component: 'modules/online/desform/DesignFormTempletList',
name: 'online-desformTemplate',
meta: {
title: '详情页',
},
},
],
},
{
path: '/list2detail',
component: 'layouts/BlankLayout',
name: 'list2detail',
meta: {
title: '列表页跳详情页2',
icon: 'gold',
},
children: [
{
path: '/list2detail/menu2',
component: 'layouts/BlankLayout',
meta: {
title: '二级菜单',
redirectDefaultChild: true,
},
children: [
{
path: '',
component: 'modules/online/desform/DesignFormList',
name: 'list2detail-menu2-online-desform',
meta: {
keepAlive: true,
title: '列表页',
},
},
{
path: '/list2detail/menu2/:id',
component: 'modules/online/desform/DesignFormTempletList',
name: 'list2detail-menu2-online-desformTemplate',
meta: {
title: '详情页',
},
},
],
},
],
},
{
path: '/bing',
component: 'layouts/BlankLayout',
name: 'bing',
meta: {
title: '必应首页',
url: 'https://cn.bing.com/',
icon: 'table',
},
},
{
path: '/dashboard',
component: 'layouts/BlankLayout',
name: 'dashboard3',
meta: {
icon: 'dashboard',
title: '系统监控',
},
children: [
{
path: '/baidu',
component: 'layouts/BlankLayout',
name: 'baidu',
meta: {
title: '百度跳转',
url: 'http://www.baidu.com',
},
},
{
path: '/isps/userAnnouncement',
component: 'title/index',
name: 'isps-userAnnouncement',
meta: {
title: '我的消息',
},
},
],
},
]
const buttonAuth = [
{
action: 'user:add',
describe: '添加用户',
type: 1,
},
{
action: 'user:edit',
describe: '编辑用户',
type: 1,
},
]
module.exports = {
mockRoutesResource,
buttonAuth,
}

56
mock/index.js Normal file
View File

@ -0,0 +1,56 @@
const Mock = require('mockjs')
const { param2Obj } = require('./utils')
const user = require('./user')
// const table = require('./table')
const mocks = [
...user,
// ...table
]
// for front mock
// please use it cautiously, it will redefine XMLHttpRequest,
// which will cause many of your third-party libraries to be invalidated(like progress event).
function mockXHR() {
// mock patch
// https://github.com/nuysoft/Mock/issues/300
Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
Mock.XHR.prototype.send = function() {
if (this.custom.xhr) {
this.custom.xhr.withCredentials = this.withCredentials || false
if (this.responseType) {
this.custom.xhr.responseType = this.responseType
}
}
this.proxy_send(...arguments)
}
function XHR2ExpressReqWrap(respond) {
return function(options) {
let result = null
if (respond instanceof Function) {
const { body, type, url } = options
// https://expressjs.com/en/4x/api.html#req
result = respond({
method: type,
body: JSON.parse(body),
query: param2Obj(url),
})
} else {
result = respond
}
return Mock.mock(result)
}
}
for (const i of mocks) {
Mock.mock(new RegExp(i.url), i.type || 'get', XHR2ExpressReqWrap(i.response))
}
}
module.exports = {
mocks,
mockXHR,
}

89
mock/mock-server.js Executable file
View File

@ -0,0 +1,89 @@
const chokidar = require('chokidar')
const bodyParser = require('body-parser')
const chalk = require('chalk')
const path = require('path')
const Mock = require('mockjs')
const express = require('express')
const mockDir = path.join(process.cwd(), '/mock')
function registerRoutes(app) {
let mockLastIndex
const { mocks } = require('./index.js')
const mocksForServer = mocks.map(route => {
return responseFake(route.url, route.type, route.response)
})
for (const mock of mocksForServer) {
const router = express.Router()
router.use(bodyParser.json())
router.use(
bodyParser.urlencoded({
extended: true,
})
)
router.use(mock.response)
app[mock.type](mock.url, router)
mockLastIndex = app._router.stack.length
}
const mockRoutesLength = Object.keys(mocksForServer).length
return {
mockRoutesLength: mockRoutesLength,
mockStartIndex: mockLastIndex - mockRoutesLength,
}
}
function unregisterRoutes() {
Object.keys(require.cache).forEach(i => {
if (i.includes(mockDir)) {
delete require.cache[require.resolve(i)]
}
})
}
// for mock server
const responseFake = (url, type, respond) => {
return {
url: new RegExp(url),
type: type || 'get',
response(req, res) {
console.log('request invoke:' + req.path)
res.json(Mock.mock(respond instanceof Function ? respond(req, res) : respond))
},
}
}
module.exports = app => {
// parse app.body
// https://expressjs.com/en/4x/api.html#req.body
// eslint-disable-next-line
const mockRoutes = registerRoutes(app)
var mockRoutesLength = mockRoutes.mockRoutesLength
var mockStartIndex = mockRoutes.mockStartIndex
// watch files, hot reload mock server
chokidar
.watch(mockDir, {
ignored: /mock-server/,
ignoreInitial: true,
})
.on('all', (event, path) => {
if (event === 'change' || event === 'add') {
try {
// remove mock routes stack
app._router.stack.splice(mockStartIndex, mockRoutesLength)
// clear routes cache
unregisterRoutes()
const mockRoutes = registerRoutes(app)
mockRoutesLength = mockRoutes.mockRoutesLength
mockStartIndex = mockRoutes.mockStartIndex
console.log(chalk.magentaBright(`\n > Mock Server hot reload success! changed ${path}`))
} catch (error) {
console.log(chalk.redBright(error))
}
}
})
}

33
mock/table.js Executable file
View File

@ -0,0 +1,33 @@
// 生成随机变量mock示例
const Mock = require('mockjs')
const data = Mock.mock({
'items|30': [
{
id: '@id',
title: '@sentence(10, 20)',
'status|1': ['published', 'draft', 'deleted'],
author: 'name',
display_time: '@datetime',
pageviews: '@integer(300, 5000)',
},
],
})
module.exports = [
{
url: '/table/list',
type: 'get',
// eslint-disable-next-line no-unused-vars
response: config => {
const items = data.items
return {
errcode: 0,
data: {
total: items.length,
items: items,
},
}
},
},
]

98
mock/user.js Executable file
View File

@ -0,0 +1,98 @@
const accounts = {
admin: {
password: '21232f297a57a5a743894a0e4a801fc3',
name: 'super Admin',
role: [],
},
user: {
password: 'ee11cbb19052e40b07aac0ca060c23ee',
name: 'nomal user',
},
}
const tokens = {
admin: {
token: 'admin-token',
},
user: {
token: 'user-token',
},
}
const { mockRoutesResource, buttonAuth } = require('./constant/userPermission')
const permission = {
menu: mockRoutesResource,
buttonAuth: buttonAuth,
}
module.exports = [
// 登录接口
{
url: '/user/login', // 支持正则
type: 'post',
response: config => {
const { username, password } = config.body
let token = undefined
if (accounts[username] && accounts[username].password === password) {
token = tokens[username]
}
// mock error
if (!token) {
return {
errcode: 1,
errmsg: '账号或密码不匹配',
}
}
return {
errcode: 0,
data: token,
}
},
},
// 获取用户信息
{
url: '/user/info',
type: 'get',
response: config => {
let token = config.headers.authorization.split('Bearer ')[1]
if (token === 'admin-token') {
return {
errcode: 0,
data: {
isAdmin: true,
username: '管理员',
avatar: '',
info: {},
},
}
} else if (token === 'user-token') {
return {
data: {
isAdmin: false,
username: '普通用户',
avatar: '',
info: {},
},
}
} else {
return {
errcode: 0,
errmsg: `token异常`,
}
}
},
},
// 获取用户权限制
{
url: '/permission/getUserPermissionByToken',
type: 'get',
response: () => {
return {
errcode: 0,
data: permission,
}
},
},
]

25
mock/utils.js Executable file
View File

@ -0,0 +1,25 @@
/**
* @param {string} url
* @returns {Object}
*/
function param2Obj(url) {
const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ') // url中, 传空格要转成+号, 这里是转回来
if (!search) {
return {}
}
const obj = {}
const searchArr = search.split('&')
searchArr.forEach(v => {
const index = v.indexOf('=')
if (index !== -1) {
const name = v.substring(0, index)
const val = v.substring(index + 1, v.length)
obj[name] = val
}
})
return obj
}
module.exports = {
param2Obj,
}

39128
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
package.json Normal file
View File

@ -0,0 +1,74 @@
{
"name": "vue-spa-scaffold",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vue-cli-service serve",
"build:prod": "vue-cli-service build",
"format": "prettier --write './**/*.{js,ts,vue,json,css,less,scss}'",
"svgo": "svgo -f src/assets/icons/svg --config=src/assets/icons/svgo.yml",
"lint": "vue-cli-service lint",
"test": "mocha"
},
"gitHooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -e $GIT_PARAMS"
},
"lint-staged": {
"*.{js,vue,ts,html}": [
"yarn run lint"
]
},
"dependencies": {
"ant-design-vue": "^1.7.4",
"axios": "^0.21.1",
"core-js": "^3.6.5",
"md5": "^2.3.0",
"nprogress": "^0.2.0",
"vue": "^2.7.14",
"vue-ls": "^3.2.2",
"vue-router": "^3.5.1",
"vuex": "^3.6.2"
},
"devDependencies": {
"@commitlint/cli": "^13.1.0",
"@commitlint/config-conventional": "^13.1.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/eslint-config-prettier": "^6.0.0",
"add": "^2.0.6",
"babel-eslint": "^10.1.0",
"babel-plugin-import": "^1.13.3",
"chai": "^4.3.4",
"chalk": "^4.1.2",
"chokidar": "2.1.5",
"clear": "^0.1.0",
"commitizen": "^4.2.4",
"compression-webpack-plugin": "3.1.0",
"cz-customizable": "^6.3.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^6.2.2",
"html-webpack-plugin": "^3.0.0",
"less": "^3.0.4",
"less-loader": "^5.0.0",
"lint-staged": "^11.1.2",
"mocha": "^8.3.2",
"mockjs": "^1.1.0",
"path": "^0.12.7",
"prettier": "^1.19.1",
"script-ext-html-webpack-plugin": "^2.1.5",
"svg-sprite-loader": "^6.0.9",
"vue-template-compiler": "^2.7.14",
"webpack": "^4.0.0",
"yarn": "^1.22.17"
},
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
}
}
}

7701
public/color.less Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

272
public/index.html Normal file
View File

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<style>
.chromeframe {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}
#loader-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999999;
}
#loader {
display: block;
position: relative;
left: 50%;
top: 50%;
width: 120px;
height: 120px;
margin: -75px 0 0 -75px;
border-radius: 50%;
border: 3px solid transparent;
/* COLOR 1 */
border-top-color: #FFF;
-webkit-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-ms-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-moz-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-o-animation: spin 2s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
animation: spin 2s linear infinite;
/* Chrome, Firefox 16+, IE 10+, Opera */
z-index: 1001;
}
#loader:before {
content: "";
position: absolute;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
border-radius: 50%;
border: 3px solid transparent;
/* COLOR 2 */
border-top-color: #FFF;
-webkit-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-moz-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-o-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-ms-animation: spin 3s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
animation: spin 3s linear infinite;
/* Chrome, Firefox 16+, IE 10+, Opera */
}
#loader:after {
content: "";
position: absolute;
top: 15px;
left: 15px;
right: 15px;
bottom: 15px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: #FFF;
/* COLOR 3 */
-moz-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-o-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-ms-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
-webkit-animation: spin 1.5s linear infinite;
/* Chrome, Opera 15+, Safari 5+ */
animation: spin 1.5s linear infinite;
/* Chrome, Firefox 16+, IE 10+, Opera */
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(0deg);
/* IE 9 */
transform: rotate(0deg);
/* Firefox 16+, IE 10+, Opera */
}
100% {
-webkit-transform: rotate(360deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(360deg);
/* IE 9 */
transform: rotate(360deg);
/* Firefox 16+, IE 10+, Opera */
}
}
@keyframes spin {
0% {
-webkit-transform: rotate(0deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(0deg);
/* IE 9 */
transform: rotate(0deg);
/* Firefox 16+, IE 10+, Opera */
}
100% {
-webkit-transform: rotate(360deg);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: rotate(360deg);
/* IE 9 */
transform: rotate(360deg);
/* Firefox 16+, IE 10+, Opera */
}
}
#loader-wrapper .loader-section {
position: fixed;
top: 0;
width: 51%;
height: 100%;
background: #49a9ee;
/* Old browsers */
z-index: 1000;
-webkit-transform: translateX(0);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateX(0);
/* IE 9 */
transform: translateX(0);
/* Firefox 16+, IE 10+, Opera */
}
#loader-wrapper .loader-section.section-left {
left: 0;
}
#loader-wrapper .loader-section.section-right {
right: 0;
}
/* Loaded */
.loaded #loader-wrapper .loader-section.section-left {
-webkit-transform: translateX(-100%);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateX(-100%);
/* IE 9 */
transform: translateX(-100%);
/* Firefox 16+, IE 10+, Opera */
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader-wrapper .loader-section.section-right {
-webkit-transform: translateX(100%);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateX(100%);
/* IE 9 */
transform: translateX(100%);
/* Firefox 16+, IE 10+, Opera */
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader {
opacity: 0;
-webkit-transition: all 0.3s ease-out;
transition: all 0.3s ease-out;
}
.loaded #loader-wrapper {
visibility: hidden;
-webkit-transform: translateY(-100%);
/* Chrome, Opera 15+, Safari 3.1+ */
-ms-transform: translateY(-100%);
/* IE 9 */
transform: translateY(-100%);
/* Firefox 16+, IE 10+, Opera */
-webkit-transition: all 0.3s 1s ease-out;
transition: all 0.3s 1s ease-out;
}
/* JavaScript Turned Off */
.no-js #loader-wrapper {
display: none;
}
.no-js h1 {
color: #222222;
}
#loader-wrapper .load_title {
font-family: 'Open Sans';
color: #FFF;
font-size: 14px;
width: 100%;
text-align: center;
z-index: 9999999999999;
position: absolute;
top: 60%;
opacity: 1;
line-height: 30px;
}
#loader-wrapper .load_title span {
font-weight: normal;
font-style: italic;
font-size: 14px;
color: #FFF;
opacity: 0.5;
}
/* 滚动条优化 start */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f6f6f6;
border-radius: 2px;
}
::-webkit-scrollbar-thumb {
background: #cdcdcd;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #747474;
}
::-webkit-scrollbar-corner {
background: #f6f6f6;
}
</style>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app">
<!-- <div>正在加载中。。。</div> -->
<div id="loader-wrapper">
<div id="loader"></div>
<div class="loader-section section-left"></div>
<div class="loader-section section-right"></div>
<div class="load_title">正在加载xxx企业开发平台,请耐心等待
</div>
</div>
<!-- built files will be auto injected -->
</body>
</html>

33
src/App.vue Normal file
View File

@ -0,0 +1,33 @@
<template>
<a-config-provider :locale="zh_CN">
<div id="app">
<router-view />
</div>
</a-config-provider>
</template>
<script>
import zh_CN from 'ant-design-vue/lib/locale-provider/zh_CN'
import moment from 'moment'
import 'moment/locale/zh-cn'
moment.locale('zh-cn')
export default {
name: 'App',
data() {
return {
zh_CN,
}
},
}
</script>
<style lang="less">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
height: 100%;
}
</style>

28
src/api/user.js Normal file
View File

@ -0,0 +1,28 @@
import request from '@/utils/request'
const urls = {
login: '/user/login', // 登录接口
getUserPermission: '/permission/getUserPermissionByToken', // 获取权限接口
getUserInfo: '/user/info', // 获取用户信息
}
export const login = params => {
return request({
url: urls.login,
method: 'post',
data: params,
})
}
export const getUserPermission = () => {
return request({
url: urls.getUserPermission,
method: 'get',
})
}
export const getUserInfo = () => {
return request({
url: urls.getUserInfo,
method: 'get',
})
}

BIN
src/assets/avatar.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

69
src/assets/background.svg Normal file
View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1361px" height="609px" viewBox="0 0 1361 609" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
<title>Group 21</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Ant-Design-Pro-3.0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="账户密码登录-校验" transform="translate(-79.000000, -82.000000)">
<g id="Group-21" transform="translate(77.000000, 73.000000)">
<g id="Group-18" opacity="0.8" transform="translate(74.901416, 569.699158) rotate(-7.000000) translate(-74.901416, -569.699158) translate(4.901416, 525.199158)">
<ellipse id="Oval-11" fill="#CFDAE6" opacity="0.25" cx="63.5748792" cy="32.468367" rx="21.7830479" ry="21.766008"></ellipse>
<ellipse id="Oval-3" fill="#CFDAE6" opacity="0.599999964" cx="5.98746479" cy="13.8668601" rx="5.2173913" ry="5.21330997"></ellipse>
<path d="M38.1354514,88.3520215 C43.8984227,88.3520215 48.570234,83.6838647 48.570234,77.9254015 C48.570234,72.1669383 43.8984227,67.4987816 38.1354514,67.4987816 C32.3724801,67.4987816 27.7006688,72.1669383 27.7006688,77.9254015 C27.7006688,83.6838647 32.3724801,88.3520215 38.1354514,88.3520215 Z" id="Oval-3-Copy" fill="#CFDAE6" opacity="0.45"></path>
<path d="M64.2775582,33.1704963 L119.185836,16.5654915" id="Path-12" stroke="#CFDAE6" stroke-width="1.73913043" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M42.1431708,26.5002681 L7.71190162,14.5640702" id="Path-16" stroke="#E0B4B7" stroke-width="0.702678964" opacity="0.7" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
<path d="M63.9262187,33.521561 L43.6721326,69.3250951" id="Path-15" stroke="#BACAD9" stroke-width="0.702678964" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
<g id="Group-17" transform="translate(126.850922, 13.543654) rotate(30.000000) translate(-126.850922, -13.543654) translate(117.285705, 4.381889)" fill="#CFDAE6">
<ellipse id="Oval-4" opacity="0.45" cx="9.13482653" cy="9.12768076" rx="9.13482653" ry="9.12768076"></ellipse>
<path d="M18.2696531,18.2553615 C18.2696531,13.2142826 14.1798519,9.12768076 9.13482653,9.12768076 C4.08980114,9.12768076 0,13.2142826 0,18.2553615 L18.2696531,18.2553615 Z" id="Oval-4" transform="translate(9.134827, 13.691521) scale(-1, -1) translate(-9.134827, -13.691521) "></path>
</g>
</g>
<g id="Group-14" transform="translate(216.294700, 123.725600) rotate(-5.000000) translate(-216.294700, -123.725600) translate(106.294700, 35.225600)">
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.25" cx="29.1176471" cy="29.1402439" rx="29.1176471" ry="29.1402439"></ellipse>
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.3" cx="29.1176471" cy="29.1402439" rx="21.5686275" ry="21.5853659"></ellipse>
<ellipse id="Oval-2-Copy" stroke="#CFDAE6" opacity="0.4" cx="179.019608" cy="138.146341" rx="23.7254902" ry="23.7439024"></ellipse>
<ellipse id="Oval-2" fill="#BACAD9" opacity="0.5" cx="29.1176471" cy="29.1402439" rx="10.7843137" ry="10.7926829"></ellipse>
<path d="M29.1176471,39.9329268 L29.1176471,18.347561 C23.1616351,18.347561 18.3333333,23.1796097 18.3333333,29.1402439 C18.3333333,35.1008781 23.1616351,39.9329268 29.1176471,39.9329268 Z" id="Oval-2" fill="#BACAD9"></path>
<g id="Group-9" opacity="0.45" transform="translate(172.000000, 131.000000)" fill="#E6A1A6">
<ellipse id="Oval-2-Copy-2" cx="7.01960784" cy="7.14634146" rx="6.47058824" ry="6.47560976"></ellipse>
<path d="M0.549019608,13.6219512 C4.12262681,13.6219512 7.01960784,10.722722 7.01960784,7.14634146 C7.01960784,3.56996095 4.12262681,0.670731707 0.549019608,0.670731707 L0.549019608,13.6219512 Z" id="Oval-2-Copy-2" transform="translate(3.784314, 7.146341) scale(-1, 1) translate(-3.784314, -7.146341) "></path>
</g>
<ellipse id="Oval-10" fill="#CFDAE6" cx="218.382353" cy="138.685976" rx="1.61764706" ry="1.61890244"></ellipse>
<ellipse id="Oval-10-Copy-2" fill="#E0B4B7" opacity="0.35" cx="179.558824" cy="175.381098" rx="1.61764706" ry="1.61890244"></ellipse>
<ellipse id="Oval-10-Copy" fill="#E0B4B7" opacity="0.35" cx="180.098039" cy="102.530488" rx="2.15686275" ry="2.15853659"></ellipse>
<path d="M28.9985381,29.9671598 L171.151018,132.876024" id="Path-11" stroke="#CFDAE6" opacity="0.8"></path>
</g>
<g id="Group-10" opacity="0.799999952" transform="translate(1054.100635, 36.659317) rotate(-11.000000) translate(-1054.100635, -36.659317) translate(1026.600635, 4.659317)">
<ellipse id="Oval-7" stroke="#CFDAE6" stroke-width="0.941176471" cx="43.8135593" cy="32" rx="11.1864407" ry="11.2941176"></ellipse>
<g id="Group-12" transform="translate(34.596774, 23.111111)" fill="#BACAD9">
<ellipse id="Oval-7" opacity="0.45" cx="9.18534718" cy="8.88888889" rx="8.47457627" ry="8.55614973"></ellipse>
<path d="M9.18534718,17.4450386 C13.8657264,17.4450386 17.6599235,13.6143199 17.6599235,8.88888889 C17.6599235,4.16345787 13.8657264,0.332739156 9.18534718,0.332739156 L9.18534718,17.4450386 Z" id="Oval-7"></path>
</g>
<path d="M34.6597385,24.809694 L5.71666084,4.76878945" id="Path-2" stroke="#CFDAE6" stroke-width="0.941176471"></path>
<ellipse id="Oval" stroke="#CFDAE6" stroke-width="0.941176471" cx="3.26271186" cy="3.29411765" rx="3.26271186" ry="3.29411765"></ellipse>
<ellipse id="Oval-Copy" fill="#F7E1AD" cx="2.79661017" cy="61.1764706" rx="2.79661017" ry="2.82352941"></ellipse>
<path d="M34.6312443,39.2922712 L5.06366663,59.785082" id="Path-10" stroke="#CFDAE6" stroke-width="0.941176471"></path>
</g>
<g id="Group-19" opacity="0.33" transform="translate(1282.537219, 446.502867) rotate(-10.000000) translate(-1282.537219, -446.502867) translate(1142.537219, 327.502867)">
<g id="Group-17" transform="translate(141.333539, 104.502742) rotate(275.000000) translate(-141.333539, -104.502742) translate(129.333539, 92.502742)" fill="#BACAD9">
<circle id="Oval-4" opacity="0.45" cx="11.6666667" cy="11.6666667" r="11.6666667"></circle>
<path d="M23.3333333,23.3333333 C23.3333333,16.8900113 18.1099887,11.6666667 11.6666667,11.6666667 C5.22334459,11.6666667 0,16.8900113 0,23.3333333 L23.3333333,23.3333333 Z" id="Oval-4" transform="translate(11.666667, 17.500000) scale(-1, -1) translate(-11.666667, -17.500000) "></path>
</g>
<circle id="Oval-5-Copy-6" fill="#CFDAE6" cx="201.833333" cy="87.5" r="5.83333333"></circle>
<path d="M143.5,88.8126685 L155.070501,17.6038544" id="Path-17" stroke="#BACAD9" stroke-width="1.16666667"></path>
<path d="M17.5,37.3333333 L127.466252,97.6449735" id="Path-18" stroke="#BACAD9" stroke-width="1.16666667"></path>
<polyline id="Path-19" stroke="#CFDAE6" stroke-width="1.16666667" points="143.902597 120.302281 174.935455 231.571342 38.5 147.510847 126.366941 110.833333"></polyline>
<path d="M159.833333,99.7453842 L195.416667,89.25" id="Path-20" stroke="#E0B4B7" stroke-width="1.16666667" opacity="0.6"></path>
<path d="M205.333333,82.1372105 L238.719406,36.1666667" id="Path-24" stroke="#BACAD9" stroke-width="1.16666667"></path>
<path d="M266.723424,132.231988 L207.083333,90.4166667" id="Path-25" stroke="#CFDAE6" stroke-width="1.16666667"></path>
<circle id="Oval-5" fill="#C1D1E0" cx="156.916667" cy="8.75" r="8.75"></circle>
<circle id="Oval-5-Copy-3" fill="#C1D1E0" cx="39.0833333" cy="148.75" r="5.25"></circle>
<circle id="Oval-5-Copy-2" fill-opacity="0.6" fill="#D1DEED" cx="8.75" cy="33.25" r="8.75"></circle>
<circle id="Oval-5-Copy-4" fill-opacity="0.6" fill="#D1DEED" cx="243.833333" cy="30.3333333" r="5.83333333"></circle>
<circle id="Oval-5-Copy-5" fill="#E0B4B7" cx="175.583333" cy="232.75" r="5.25"></circle>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
src/assets/checkcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

9
src/assets/icons/index.js Executable file
View File

@ -0,0 +1,9 @@
import Vue from 'vue'
import SvgIcon from '@/components/SvgIcon' // svg组件
// register globally
Vue.component('SvgIcon', SvgIcon)
const req = require.context('./svg', false, /\.svg$/)
const requireAll = requireContext => requireContext.keys().map(requireContext)
requireAll(req)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><defs><style/></defs><path d="M529.05 527.616l-30.772-30.746 85.07-85.094 30.77 30.771z"/><path d="M0 340.48l427.52 256L675.84 1024 1024 0 0 340.48zM665.6 921.6L458.24 565.76 102.4 353.28 911.36 81.92l-243.2 243.2 30.72 30.72 243.2-243.2L665.6 921.6z"/></svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@ -0,0 +1 @@
<svg class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path d="M511.19 700.71c-102.96 0-186.73-83.77-186.73-186.73 0-102.93 83.77-186.7 186.73-186.7 102.93 0 186.7 83.77 186.7 186.7 0 102.96-83.77 186.73-186.7 186.73zm0-299.26c-62.08 0-112.56 50.49-112.56 112.53 0 62.08 50.49 112.56 112.56 112.56 62.04 0 112.53-50.49 112.53-112.56 0-62.05-50.49-112.53-112.53-112.53zM915.01 481.56c-143.78 15.72-255.7 137.46-255.7 285.43 0 35.59 6.57 69.62 18.41 101.08a402.92 402.92 0 0084.23-51.09c-.05-2.24-.42-4.4-.42-6.66 0-108.86 60.57-203.56 149.84-252.28 2.71-18.89 4.6-38.05 4.6-57.7-.01-6.33-.67-12.51-.96-18.78z"/><path d="M253.25 231.34c-10.83 0-21.51-4.71-28.86-13.73-12.89-15.94-10.43-39.3 5.47-52.19 36.07-29.19 76.45-52.59 120.06-69.54 19.05-7.5 40.6 2.03 47.99 21.11 7.42 19.09-2.03 40.56-21.11 47.99-36.43 14.16-70.15 33.72-100.21 58.09-6.89 5.55-15.15 8.27-23.34 8.27zm515.81 0c-8.26 0-16.55-2.75-23.47-8.4-29.55-24.19-63.09-43.71-99.71-57.95-19.09-7.42-28.54-28.9-21.11-47.99 7.39-19.09 28.94-28.65 47.99-21.11 43.89 17.06 84.2 40.49 119.84 69.68 15.83 12.97 18.18 36.33 5.18 52.19-7.32 8.94-17.97 13.58-28.72 13.58zM659.32 934.49c-14.85 0-28.83-8.95-34.55-23.61-7.42-19.09 1.99-40.6 21.08-48.02 36.69-14.31 70.26-33.79 99.74-57.95 15.83-13.04 39.26-10.65 52.19 5.18 13 15.86 10.65 39.22-5.18 52.19-35.53 29.12-75.84 52.55-119.81 69.68a36.863 36.863 0 01-13.47 2.53zM105.2 614.8c-18.18 0-34.04-13.33-36.69-31.83-3.44-24.12-5.18-47.34-5.18-69.07 0-24.19 1.74-46.76 5.29-68.88 3.26-20.25 22.24-34.08 42.48-30.75 20.25 3.26 34.01 22.27 30.75 42.48-2.93 18.22-4.35 36.94-4.35 57.15 0 18.22 1.48 37.92 4.45 58.56 2.9 20.28-11.19 39.08-31.47 41.98-1.77.25-3.54.36-5.28.36zm258.15 319.69c-4.49 0-9.05-.8-13.47-2.54-43.68-17.02-84.06-40.42-120.02-69.54-15.9-12.89-18.36-36.25-5.47-52.19 12.89-15.83 36.22-18.33 52.19-5.47 29.99 24.3 63.71 43.86 100.25 58.09 19.09 7.42 28.5 28.94 21.08 48.02-5.73 14.68-19.74 23.63-34.56 23.63zM917.08 614.8c-1.74 0-3.51-.11-5.29-.36-20.28-2.9-34.37-21.69-31.47-41.98 2.97-20.79 4.49-40.53 4.49-58.56 0-20.28-1.45-38.93-4.38-57.08-3.26-20.21 10.47-39.26 30.68-42.55 20.14-3.33 39.26 10.47 42.55 30.68 3.59 22.09 5.32 44.66 5.32 68.96 0 21.55-1.77 44.8-5.22 69.07-2.64 18.49-18.5 31.82-36.68 31.82z"/><path d="M511.34 242.06c-69.97 0-136.43-33.5-177.83-89.6-12.17-16.48-8.66-39.69 7.82-51.86 16.48-12.17 39.69-8.62 51.86 7.82 27.89 37.81 70.95 59.47 118.14 59.47s90.25-21.66 118.14-59.47c12.17-16.44 35.38-19.99 51.86-7.82s19.99 35.38 7.82 51.86c-41.38 56.1-107.84 89.6-177.81 89.6zm148.02 692.43c-11.37 0-22.6-5.22-29.88-15.07-27.89-37.81-70.95-59.5-118.14-59.5s-90.25 21.69-118.14 59.5c-12.17 16.48-35.38 19.99-51.86 7.82-16.48-12.17-19.99-35.38-7.82-51.86 41.4-56.14 107.89-89.64 177.83-89.64s136.43 33.5 177.83 89.64c12.17 16.48 8.66 39.69-7.82 51.86-6.65 4.89-14.36 7.25-22 7.25zM105.2 487.97c-18.51 0-34.51-13.83-36.8-32.67-2.43-20.35 12.1-38.82 32.41-41.25 46.76-5.58 87.1-32.2 110.64-72.94 23.54-40.53 26.33-88.7 7.68-132.26-8.08-18.83.65-40.64 19.48-48.68 18.65-8.19 40.6.58 48.68 19.48 27.6 64.36 23.22 138.6-11.66 198.61-35.28 61.13-95.83 101.05-165.98 109.45-1.52.19-3 .26-4.45.26zm663.93 382.7c-14.38 0-28.03-8.37-34.08-22.38-27.71-64.1-23.36-138.28 11.63-198.4 34.41-60.34 96.59-101.19 166.2-109.05 20.72-2.03 38.75 12.35 41.03 32.7 2.28 20.35-12.35 38.72-32.7 41.03-46.86 5.25-87.07 31.62-110.24 72.33-23.36 40.16-26.22 89.38-7.82 131.97 8.11 18.8-.54 40.64-19.34 48.75a36.808 36.808 0 01-14.68 3.05zm147.95-382.7c-1.45 0-2.93-.07-4.45-.25-70.15-8.4-130.67-48.31-166.02-109.52-34.84-59.94-39.22-134.22-11.59-198.58 8.08-18.83 29.88-27.6 48.71-19.45 18.83 8.08 27.52 29.88 19.45 48.71-18.69 43.53-15.9 91.7 7.61 132.12 23.61 40.89 63.92 67.47 110.68 73.05 20.32 2.43 34.84 20.9 32.41 41.25-2.29 18.83-18.3 32.67-36.8 32.67zm-663.86 382.7c-4.93 0-9.89-.98-14.7-3.04-18.8-8.11-27.45-29.92-19.34-48.75 18.36-42.59 15.5-91.81-7.64-131.72-23.4-40.96-63.6-67.33-110.46-72.58-20.35-2.32-34.99-20.68-32.7-41.03 2.28-20.35 20.39-34.88 41.03-32.7 69.57 7.86 131.79 48.71 166.38 109.3 34.8 59.87 39.15 134.04 11.48 198.14-6.06 14.02-19.71 22.38-34.05 22.38z"/></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

22
src/assets/icons/svgo.yml Executable file
View File

@ -0,0 +1,22 @@
# replace default config
# multipass: true
# full: true
plugins:
# - name
#
# or:
# - name: false
# - name: true
#
# or:
# - name:
# param1: 1
# param2: 2
- removeAttrs:
attrs:
- 'fill'
- 'fill-rule'

View File

@ -0,0 +1,31 @@
@header-height: 54px;
@tab-height: 40px;
@gap: 4px;
@router-wrapper-1-padding-y: 20px;
.router-wrapper-1-height-top-tabs(){
height: calc(100vh - @header-height - @tab-height - @gap * 3 - @router-wrapper-1-padding-y * 2);
}
.router-wrapper-1-height-no-top-tabs(){
height: calc(100vh - @header-height - @gap * 3 - @router-wrapper-1-padding-y * 2);
}
.layout-content-height(){
.ant-tabs-top-tabs {
.router-wrapper-1-height-top-tabs();
}
.router-wrapper-2-top-tabs {
.router-wrapper-1-height-top-tabs();
padding-right: 24px;
overflow: auto;
}
.ant-tabs-no-top-tabs {
.router-wrapper-1-height-no-top-tabs();
}
.router-wrapper-2-no-top-tabs {
.router-wrapper-1-height-no-top-tabs();
padding-right: 24px;
overflow: auto;
}
}

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

185
src/components/Header.vue Normal file
View File

@ -0,0 +1,185 @@
<template>
<a-layout-header :style="{ backgroundColor: navTheme === 'light' ? primaryColor : '#fff' }">
<div>
<a-icon
class="trigger"
:type="collapsed ? 'menu-unfold' : 'menu-fold'"
@click="$emit('update:collapsed', !collapsed)"
:style="{ verticalAlign: 'top', ...color }"
/>
<h1
:style="{
display: 'inline-block',
...color,
}"
>
XXX后台管理系统
</h1>
</div>
<a-space size="small" style="margin-right: 36px">
<a href="https://www.baidu.com/" target="_blank" class="header-link"
><a-icon type="question-circle" :style="color"
/></a>
<a-popover trigger="click" class="header-link" placement="bottomRight">
<div slot="content">
<a-tabs default-active-key="1">
<a-tab-pane key="1" tab="通知(0)">
<a-button type="dashed" block @click="goToNoticePage('notice')">
Content of Tab Pane 1
</a-button>
</a-tab-pane>
<a-tab-pane key="2" tab="系统消息(0)" force-render>
<a-button type="dashed" block @click="goToNoticePage('message')">
Content of Tab Pane 2
</a-button>
</a-tab-pane>
</a-tabs>
</div>
<a-icon type="bell" :style="color" />
</a-popover>
<a-dropdown>
<a class="ant-dropdown-link" @click="e => e.preventDefault()">
<a-avatar
class="avatar"
style="margin-right: 12px"
src="https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png"
/>
<span :style="color">欢迎您{{ 'xxx' }}</span>
</a>
<a-menu slot="overlay">
<a-menu-item key="0">
<router-link :to="{ name: 'account-center' }">
<a-icon type="user" style="margin-right: 8px" />
<span>个人中心</span>
</router-link>
</a-menu-item>
<a-menu-item key="1">
<!-- <router-link :to="{ name: 'account-settings-notification' }"> -->
<router-link :to="{ name: 'account-settings-Index' }">
<a-icon type="setting" style="margin-right: 8px" />
<span>账户设置</span>
</router-link>
</a-menu-item>
<a-menu-item key="3" @click="systemSetting">
<a-icon type="tool" />
<span>系统设置</span>
</a-menu-item>
<a-menu-item key="4" @click="updatePassword">
<a-icon type="setting" />
<span>密码修改</span>
</a-menu-item>
<a-menu-item key="5" @click="updateCurrentDepart">
<a-icon type="cluster" />
<span>切换部门</span>
</a-menu-item>
</a-menu>
</a-dropdown>
<a href="javascript:;" class="header-link" @click="handleLogout">
<a-icon type="logout" :style="color" />
<span :style="color">&nbsp;退出登录</span>
</a>
<user-password ref="userPassword"></user-password>
<setting-drawer :visible.sync="visible" />
</a-space>
</a-layout-header>
</template>
<script>
import UserPassword from '@/components/UserPassword'
import SettingDrawer from '@/components/SettingDrawer'
import { mapState } from 'vuex'
export default {
name: 'Header',
props: {
collapsed: {
type: Boolean,
required: false,
default: false,
},
},
components: {
UserPassword,
SettingDrawer,
},
data() {
return {
visible: false,
}
},
computed: {
...mapState({
navTheme: state => state.app.navTheme,
primaryColor: state => state.app.primaryColor,
}),
color() {
return {
color: this.navTheme === 'light' ? '#fff' : '#000',
}
},
},
methods: {
goToNoticePage(type) {
// if (
// this.$route.name === "isps-userAnnouncement" &&
// this.$route.query.type === type
// ) {
// return;
// } else {
this.$router.push({
name: 'isps-userAnnouncement',
query: { type },
})
// }
},
systemSetting() {
this.visible = true
},
updatePassword() {
this.$refs.userPassword.show()
},
updateCurrentDepart() {
this.$message.warning('您尚未设置部门信息!')
},
handleLogout() {
let that = this
this.$confirm({
title: '提示',
content: '真的要注销登录吗 ?',
onOk() {
that.$store.dispatch('user/logout')
},
onCancel() {},
})
},
},
}
</script>
<style lang="less" scoped>
@import '~@/assets/less/common';
.trigger {
font-size: 18px;
line-height: @header-height;
padding: 0 24px;
cursor: pointer;
transition: color 0.3s;
}
.trigger:hover {
color: #1890ff;
}
.ant-dropdown-link {
height: 100%;
display: inline-block;
padding: 0 12px;
color: #2c3e50;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
}
.header-link {
line-height: 3;
.ant-dropdown-link();
}
</style>

View File

@ -0,0 +1,178 @@
<template>
<a-drawer width="300" placement="right" @close="onClose" :closable="false" :visible="visible">
<div :style="{ marginBottom: '24px' }">
<h3>整体风格设置</h3>
<div class="setting-drawer-index-blockChecbox">
<a-tooltip>
<template slot="title"> 暗色菜单风格 </template>
<div class="setting-drawer-index-item" @click="handleNavTheme('dark')">
<img
src="https://gw.alipayobjects.com/zos/rmsportal/LCkqqYNmvBEbokSDscrm.svg"
alt="dark"
/>
<div class="setting-drawer-index-selectIcon" v-if="navTheme === 'dark'">
<a-icon type="check" />
</div>
</div>
</a-tooltip>
<a-tooltip>
<template slot="title"> 亮色菜单风格 </template>
<div class="setting-drawer-index-item" @click="handleNavTheme('light')">
<img
src="https://gw.alipayobjects.com/zos/rmsportal/jpRkZQMyYRryryPNtyIC.svg"
alt="light"
/>
<div class="setting-drawer-index-selectIcon" v-if="navTheme === 'light'">
<a-icon type="check" />
</div>
</div>
</a-tooltip>
</div>
</div>
<div>
<h3>主题色</h3>
<div style="height: 20px">
<a-tooltip
class="setting-drawer-theme-color-colorBlock"
v-for="(item, index) in colorList"
:key="index"
>
<template slot="title">
{{ item.key }}
</template>
<a-tag :color="item.color" @click="changeColor(item.color)">
<a-icon type="check" v-if="item.color === primaryColor"></a-icon>
</a-tag>
</a-tooltip>
</div>
</div>
<a-divider style="margin-top: 24px" />
<div :style="{ marginBottom: '24px' }">
<h3>其他设置</h3>
<div>
<a-list :split="false">
<a-list-item>
<a-switch slot="actions" size="small" :defaultChecked="multiTab" @change="onMultiTab" />
<a-list-item-meta>
<div slot="title">多页签模式</div>
</a-list-item-meta>
</a-list-item>
</a-list>
</div>
</div>
<a-divider />
<div v-if="showTip">
<a-button @click="doCopy" icon="copy" block>打印配置到控制台</a-button>
<div :style="{ marginBottom: '24px' }">
<a-alert type="warning">
<span slot="message">
配置栏只在开发环境用于预览生产环境不会展现请手动修改配置文件
<a
href="https://github.com/sendya/ant-design-pro-vue/blob/master/src/config/defaultSettings.js"
target="_blank"
>src/config/defaultSettings.js</a
>
</span>
</a-alert>
</div>
</div>
</a-drawer>
</template>
<script>
import { mapState } from 'vuex'
import { colorList } from './settingConfig'
export default {
name: 'SettingDrawer',
props: {
visible: {
type: Boolean,
required: true,
default: false,
},
},
data() {
return {
colorList,
showTip: process.env.NODE_ENV === 'development',
}
},
computed: {
...mapState({
primaryColor: state => state.app.primaryColor,
multiTab: state => state.app.multiTab,
navTheme: state => state.app.navTheme,
}),
},
methods: {
onClose() {
this.$emit('update:visible', false)
},
changeColor(color) {
if (this.primaryColor !== color) this.$store.dispatch('app/toggleColor', color)
},
handleNavTheme(navTheme) {
this.$store.commit('app/toggleNavTheme', navTheme)
},
onMultiTab(isMultiTab) {
this.$store.commit('app/setMultiTab', isMultiTab)
},
doCopy() {
const text = `export default {
primaryColor: '${this.primaryColor}', // primary color of ant design
navTheme: '${this.navTheme}', // theme for nav menu
multiTab: ${this.multiTab},
production: process.env.NODE_ENV === 'production' && process.env.VUE_APP_PREVIEW !== 'true'}`
console.log(text)
},
},
}
</script>
<style lang="less" scoped>
.setting-drawer-index-blockChecbox {
display: flex;
.setting-drawer-index-item {
margin-right: 16px;
position: relative;
border-radius: 4px;
cursor: pointer;
img {
width: 48px;
}
.setting-drawer-index-selectIcon {
position: absolute;
top: 0;
right: 0;
width: 100%;
padding-top: 15px;
padding-left: 24px;
height: 100%;
color: #1890ff;
font-size: 14px;
font-weight: 700;
}
}
}
.setting-drawer-theme-color-colorBlock {
width: 20px;
height: 20px;
border-radius: 2px;
float: left;
cursor: pointer;
margin-right: 8px;
padding-left: 0px;
padding-right: 0px;
text-align: center;
color: #fff;
font-weight: 700;
}
</style>

View File

@ -0,0 +1,97 @@
import message from 'ant-design-vue/es/message'
let lessNodesAppended
const colorList = [
{
key: '薄暮',
color: '#F5222D',
},
{
key: '火山',
color: '#FA541C',
},
{
key: '日暮',
color: '#FAAD14',
},
{
key: '明青',
color: '#13C2C2',
},
{
key: '极光绿',
color: '#52C41A',
},
{
key: '拂晓蓝(默认)',
color: '#1890FF',
},
{
key: '极客蓝',
color: '#2F54EB',
},
{
key: '酱紫',
color: '#722ED1',
},
]
const updateTheme = primaryColor => {
// Don't compile less in production!
/* if (process.env.NODE_ENV === 'production') {
return;
} */
// Determine if the component is remounted
if (!primaryColor) {
return
}
const hideMessage = message.loading('正在编译主题!', 3)
console.info(`正在编译主题!`)
function buildIt() {
// 正确的判定less是否已经加载less.modifyVars可用
if (!window.less || !window.less.modifyVars) {
return
}
// less.modifyVars可用
window.less
.modifyVars({
'@primary-color': primaryColor,
})
.then(() => {
hideMessage()
})
.catch(() => {
message.error('Failed to update theme')
hideMessage()
})
}
if (!lessNodesAppended) {
// insert less.js and color.less
const lessStyleNode = document.createElement('link')
const lessConfigNode = document.createElement('script')
const lessScriptNode = document.createElement('script')
lessStyleNode.setAttribute('rel', 'stylesheet/less')
lessStyleNode.setAttribute('href', '/color.less')
lessConfigNode.innerHTML = `
window.less = {
async: true,
env: 'production',
javascriptEnabled: true
};
`
lessScriptNode.src = 'https://gw.alipayobjects.com/os/lib/less.js/3.8.1/less.min.js'
lessScriptNode.async = true
lessScriptNode.onload = () => {
buildIt()
lessScriptNode.onload = null
}
document.body.appendChild(lessStyleNode)
document.body.appendChild(lessConfigNode)
document.body.appendChild(lessScriptNode)
lessNodesAppended = true
} else {
buildIt()
}
}
export { updateTheme, colorList }

View File

@ -0,0 +1,71 @@
<template>
<div
v-if="externalIcon"
:style="styleExternalIcon"
class="svg-external-icon svg-icon"
v-on="$listeners"
/>
<svg v-else :class="svgClass" aria-hidden="true" v-on="$listeners">
<use :xlink:href="iconName" />
</svg>
</template>
<script>
// doc: https://panjiachen.github.io/vue-element-admin-site/feature/component/svg-icon.html#usage
export default {
name: 'SvgIcon',
props: {
iconClass: {
type: String,
required: true,
},
className: {
type: String,
default: '',
},
},
computed: {
externalIcon() {
return this.isExternal(this.iconClass)
},
iconName() {
return `#icon-${this.iconClass}`
},
svgClass() {
if (this.className) {
return 'svg-icon ' + this.className
} else {
return 'svg-icon'
}
},
styleExternalIcon() {
return {
mask: `url(${this.iconClass}) no-repeat 50% 50%`,
'-webkit-mask': `url(${this.iconClass}) no-repeat 50% 50%`,
}
},
},
methods: {
isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
},
},
}
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.svg-external-icon {
background-color: currentColor;
mask-size: cover !important;
display: inline-block;
}
</style>

227
src/components/TopTabs.vue Normal file
View File

@ -0,0 +1,227 @@
<template>
<div>
<!-- 右键操作菜单 -->
<contextmenu
:itemList="menuItemList"
:visible.sync="menuVisible"
:position="menuPosition"
@select="onMenuSelect"
/>
<a-tabs
v-model="activeKey"
type="editable-card"
@edit="onEdit"
@contextmenu.native="onContextmenu"
:hideAdd="true"
>
<a-tab-pane v-for="pane in panes" :key="pane.key" :closable="pane.closable">
<span slot="tab" :paneKey="pane.key">{{ pane.title }}</span>
{{ pane.content }}
</a-tab-pane>
</a-tabs>
</div>
</template>
<script>
import Contextmenu from '@/components/menu/Contextmenu'
import { mapGetters } from 'vuex'
export default {
name: 'TopTabs',
props: {
isShow: {
type: Boolean,
required: true,
},
},
components: {
Contextmenu,
},
data() {
const panes = [{ title: '首页', key: '/home', closable: false }]
const menuItemList = [
{ key: '4', icon: 'reload', text: '刷 新' },
{ key: '1', icon: 'arrow-left', text: '关闭左侧' },
{ key: '2', icon: 'arrow-right', text: '关闭右侧' },
{ key: '3', icon: 'close', text: '关闭其它' },
]
return {
activeKey: panes[0].key,
panes,
contextPaneKey: '',
menuVisible: false,
menuItemList,
menuPosition: {
left: '0px',
top: '0px',
},
}
},
computed: {
...mapGetters({
mapMenu: 'user/mapMenu',
}),
},
watch: {
$route: {
immediate: true,
handler(newRoute) {
// const parentResource = this.findParentResource(
// newRoute.path,
// this.routesResource,
// {}
// );
// const currentResource = this.mapMenu[newRoute.path]
// if (currentResource.alwaysShow) {
// const pane = this.panes.find(item =>
// currentResource.children.some(child => child.path === item.key)
// )
// if (!pane) {
// this.panes.push({
// title: currentResource.meta.title,
// key: newRoute.path,
// })
// } else {
// pane.key = newRoute.path
// }
// } else if (!this.panes.find(item => item.key === newRoute.path)) {
// this.panes.push({ title: newRoute.meta.title, key: newRoute.path })
// }
if (!this.panes.find(item => item.key === newRoute.path)) {
this.panes.push({ title: newRoute.meta.title, key: newRoute.path })
}
this.activeKey = newRoute.path
},
},
activeKey(newKey) {
// if (this.$route.path !== newKey) {
this.$router.push({ path: newKey })
// }
},
},
methods: {
// findParentResource(path, children, parent) {
// for (const item of children) {
// if (item.path === path) {
// return parent;
// } else if (item.children) {
// const parentResource = this.findParentResource(
// path,
// item.children,
// item
// );
// if (parentResource) {
// return parentResource;
// }
// }
// }
// },
onEdit(targetKey, action) {
this[action](targetKey)
},
remove(targetKey) {
let activeKey = this.activeKey
let lastIndex
this.panes.forEach((pane, i) => {
if (pane.key === targetKey) {
lastIndex = i - 1
}
})
const panes = this.panes.filter(pane => pane.key !== targetKey)
if (panes.length && activeKey === targetKey) {
if (lastIndex >= 0) {
activeKey = panes[lastIndex].key
} else {
activeKey = panes[0].key
}
}
this.panes = panes
this.activeKey = activeKey
},
onContextmenu(e) {
this.contextPaneKey = this.getPageKey(e.target)
if (this.contextPaneKey !== null) {
e.preventDefault()
this.menuPosition.left = e.clientX + 'px'
this.menuPosition.top = e.clientY + 'px'
this.menuVisible = true
}
},
getPageKey(target) {
const spans = target.parentElement.querySelectorAll('span[paneKey]')
if (spans.length === 1) {
return spans[0].getAttribute('paneKey')
} else {
return null
}
},
onMenuSelect(key) {
let contextIndex, activeIndex
this.panes.forEach((pane, i) => {
if (pane.key === this.contextPaneKey) {
contextIndex = i
}
if (pane.key === this.activeKey) {
activeIndex = i
}
})
switch (key) {
case '1':
this.closeLeft(contextIndex, activeIndex)
break
case '2':
this.closeRight(contextIndex, activeIndex)
break
case '3':
this.closeOthers(contextIndex, activeIndex)
break
case '4':
this.routeReload()
break
default:
break
}
},
closeLeft(contextIndex, activeIndex) {
if (activeIndex < contextIndex && activeIndex !== 0) {
this.activeKey = this.panes[contextIndex].key
}
this.panes.splice(1, contextIndex - 1)
},
closeRight(contextIndex, activeIndex) {
if (activeIndex > contextIndex) {
this.activeKey = this.panes[contextIndex].key
}
this.panes = this.panes.slice(0, contextIndex + 1)
},
closeOthers(contextIndex, activeIndex) {
if (activeIndex !== contextIndex && activeIndex !== 0) {
this.activeKey = this.panes[contextIndex].key
}
if (contextIndex === 0) {
this.panes = [this.panes[0]]
} else {
this.panes = [this.panes[0], this.panes[contextIndex]]
}
},
routeReload() {
this.$emit('update:isShow', false)
this.$nextTick(() => {
this.$emit('update:isShow', true)
})
},
},
}
</script>
<style lang="less" scoped>
/deep/ .ant-tabs-bar.ant-tabs-top-bar.ant-tabs-card-bar {
margin-bottom: 0;
border-bottom: 1px solid #e8e8e8;
background-color: #fff;
}
// /deep/ .ant-tabs.ant-tabs-card > .ant-tabs-bar .ant-tabs-tab-active {
// border-color: #e8e8e8 !important;
// }
</style>

View File

@ -0,0 +1,163 @@
<template>
<a-modal
title="修改密码"
:width="800"
:visible="visible"
:confirmLoading="confirmLoading"
@ok="handleOk"
@cancel="close"
cancelText="关闭"
>
<a-spin :spinning="confirmLoading">
<a-form :form="form">
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" label="旧密码">
<a-input
type="password"
placeholder="请输入旧密码"
v-decorator="['oldPassword', validatorRules.oldPassword]"
/>
</a-form-item>
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" label="新密码">
<a-input
type="password"
placeholder="请输入新密码"
v-decorator="['password', validatorRules.password]"
/>
</a-form-item>
<a-form-item :labelCol="labelCol" :wrapperCol="wrapperCol" label="确认新密码">
<a-input
type="password"
@blur="handleConfirmBlur"
placeholder="请确认新密码"
v-decorator="['confirmPassword', validatorRules.confirmPassword]"
/>
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script>
export default {
name: 'UserPassword',
data() {
return {
visible: false,
confirmLoading: false,
validatorRules: {
oldPassword: {
rules: [
{
required: true,
message: '请输入旧密码!',
},
],
},
password: {
rules: [
{
required: true,
message: '请输入新密码!',
},
{
pattern: /^(?![0-9]+$)(?![a-zA-Z]+$)(?!([^0-9a-zA-Z])+$)\S{6,20}$/,
message:
'6-20位字符只能包含大小写字母、数字及标点符号除空格大小写字母·数字和标点符号至少包含2种',
},
{
validator: this.validateToNextPassword,
},
],
},
confirmPassword: {
rules: [
{
required: true,
message: '请确认新密码!',
},
{
pattern: /^(?![0-9]+$)(?![a-zA-Z]+$)(?!([^0-9a-zA-Z])+$)\S{6,20}$/,
message:
'6-20位字符只能包含大小写字母、数字及标点符号除空格大小写字母·数字和标点符号至少包含2种',
},
{
validator: this.compareToFirstPassword,
},
],
},
},
confirmDirty: false,
labelCol: {
sm: { span: 5 },
},
wrapperCol: {
sm: { span: 16 },
},
form: this.$form.createForm(this),
}
},
methods: {
show() {
this.form.resetFields()
this.visible = true
},
close() {
this.visible = false
},
handleOk() {
//
this.form.validateFields((err, values) => {
if (!err) {
console.log(values)
this.confirmLoading = true
new Promise(resolve => {
setTimeout(
() =>
resolve({
success: true,
message: '修改密码成功',
}),
3000
)
})
.then(res => {
if (res.success) {
console.log(res)
this.$message.success(res.message)
this.close()
} else {
this.$message.warning(res.message)
}
})
.finally(() => {
this.confirmLoading = false
})
}
})
},
validateToNextPassword(rule, value, callback) {
const form = this.form
if (value && this.confirmDirty) {
form.validateFields(['confirm'], { force: true })
}
callback()
},
compareToFirstPassword(rule, value, callback) {
const form = this.form
if (value && value !== form.getFieldValue('password')) {
callback('两次输入的密码不一样!')
} else {
callback()
}
},
handleConfirmBlur(e) {
const value = e.target.value
this.confirmDirty = this.confirmDirty || !!value
},
},
}
</script>
<style scoped></style>

View File

@ -0,0 +1,21 @@
<template>
<div>
<keep-alive>
<router-view v-if="keepAlive"></router-view>
</keep-alive>
<router-view v-if="!keepAlive"></router-view>
</div>
</template>
<script>
export default {
name: 'BlankLayout',
computed: {
keepAlive() {
return this.$route.meta.keepAlive
},
},
}
</script>
<style></style>

View File

@ -0,0 +1,161 @@
<template>
<a-layout id="components-layout-demo-custom-trigger">
<a-layout-sider v-model="collapsed" :trigger="null" collapsible>
<div>
<div class="logo" :style="logoNavTheme">
<img src="@/assets/logo.png" alt="logo" />
<transition name="slide-fade">
<span v-if="!collapsed"> Logo </span>
</transition>
</div>
<side-menu :collapsed="collapsed" class="side-menu" />
</div>
</a-layout-sider>
<a-layout>
<Header :collapsed.sync="collapsed" />
<a-layout-content>
<TopTabs :isShow.sync="isShow" v-if="$store.state.app.multiTab" />
<div
:class="
$store.state.app.multiTab
? 'router-wrapper-1-height-top-tabs'
: 'router-wrapper-1-height-no-top-tabs'
"
>
<div v-if="isShow">
<keep-alive>
<router-view v-if="keepAlive"></router-view>
</keep-alive>
<router-view v-if="!keepAlive"></router-view>
</div>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script>
import SideMenu from '@/components/menu/SideMenu'
import Header from '@/components/Header'
import TopTabs from '@/components/TopTabs'
import { mapState } from 'vuex'
export default {
name: 'GlobalLayout',
data() {
return {
isShow: true,
collapsed: false,
}
},
components: {
SideMenu,
Header,
TopTabs,
},
computed: {
...mapState({
primaryColor: state => state.app.primaryColor,
navTheme: state => state.app.navTheme,
menu: state => state.user.menu,
}),
logoNavTheme() {
if (this.navTheme === 'dark') {
return {
background: '#002140',
color: 'white',
borderRight: '1px #002140 solid',
borderBottom: '1px #002140 solid',
}
} else {
return {
background: this.primaryColor,
color: 'white',
// borderRight: `1px #f0f2f5 solid`,
borderBottom: `1px #f0f2f5 solid`,
}
}
},
keepAlive() {
return this.$route.matched[1].meta?.keepAlive
},
},
created() {
if (this.primaryColor !== '#1890FF') {
this.$store.dispatch('app/toggleColor', this.primaryColor)
}
},
}
</script>
<style lang="less" scoped>
@import '../~@/assets/less/common';
#components-layout-demo-custom-trigger {
// height: 32px;
// background: rgba(255, 255, 255, 0.2);
// margin: 16px;
.logo {
overflow: hidden;
display: flex;
height: @header-height+1px;
line-height: @header-height+1px;
padding-left: 24px;
font-weight: 700;
font-size: 20px;
img {
height: 100%;
}
}
}
.ant-layout-sider .side-menu {
height: calc(100vh - (@header-height + 1px));
overflow: auto;
&::-webkit-scrollbar {
display: none;
}
}
.ant-layout-header {
height: @header-height;
line-height: @header-height;
background: #fff;
padding: 0;
display: flex;
justify-content: space-between;
}
.ant-layout-content {
margin: @gap 0 @gap @gap;
padding: @gap 0 0 @gap;
background: #fff;
}
.router-wrapper-1-height-top-tabs {
height: calc(100vh - @header-height - @tab-height - @gap * 3);
overflow: auto;
padding: @router-wrapper-1-padding-y 16px;
}
.router-wrapper-1-height-no-top-tabs {
height: calc(100vh - @header-height - @gap * 3);
overflow: auto;
padding: @router-wrapper-1-padding-y 16px;
}
.slide-fade-enter-active {
animation: slide-fade 0.5s;
}
.slide-fade-leave-active {
animation: slide-fade 0.5s reverse;
}
@keyframes slide-fade {
0% {
opacity: 0;
transform: translateX(20px);
}
50% {
opacity: 0.5;
transform: translateX(10px);
}
100% {
opacity: 1;
transform: translateX(0px);
}
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<!-- <div> -->
<a-tabs
v-model="activeKey"
tab-position="left"
:forceRender="false"
:class="$store.state.app.multiTab ? 'ant-tabs-top-tabs' : 'ant-tabs-no-top-tabs'"
>
<a-tab-pane v-for="pane in children" :key="pane.path" :tab="pane.meta.title"
><div
:class="
$store.state.app.multiTab ? 'router-wrapper-2-top-tabs' : 'router-wrapper-2-no-top-tabs'
"
>
<!-- {{ pane.meta.title }}
<div v-if="pane.meta.title === '基本设置'">
<a-input></a-input>
<p v-for="i in 50" :key="i">基本设置{{ i }}</p>
</div>
{{ pane.path }} -->
<component :is="pane.path"></component>
</div>
</a-tab-pane>
</a-tabs>
<!-- </div> -->
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'SideTabsLayout',
data() {
return {
activeKey: '',
children: [],
}
},
computed: {
...mapGetters({
mapMenu: 'user/mapMenu',
}),
},
created() {
// const path = this.$route.matched.find(
// item => item.components.default.name === this.$options.name
// ).path
const path = this.$route.path
// this.children = this.findChildrenResource(path, this.routesResource) || [];
this.children = this.mapMenu[path].children
// , components, tab
let components = {}
for (const key of this.children) {
components[key.path] = () => import(`@/views/${key.component}`)
}
this.$options.components = Object.assign(this.$options.components, components)
if (this.children.length > 0) {
this.activeKey = this.children[0].path
}
},
// methods: {
// findChildrenResource(path, children) {
// for (const item of children) {
// if (item.path === path) {
// return item.children;
// } else if (item.children) {
// const children = this.findChildrenResource(path, item.children);
// if (children) {
// return children;
// }
// }
// }
// }
// }
}
</script>
<style lang="less" scoped>
@import '../~@/assets/less/common';
.layout-content-height();
</style>

View File

@ -0,0 +1,71 @@
<template>
<div id="userLayout">
<div class="container">
<div class="top">
<div class="header">
<a href="/">
<img src="~@/assets/logo.png" class="logo" alt="logo" />
<span class="title">Ant Design</span>
</a>
</div>
<div class="desc">Ant Design 是西湖区最具影响力的 Web 设计规范</div>
</div>
<router-view />
</div>
</div>
</template>
<script>
export default {
name: 'UserLayout',
}
</script>
<style lang="less" scoped>
#userLayout {
height: 100%;
.container {
width: 100%;
min-height: 100%;
background: #f0f2f5 url(~@/assets/background.svg) no-repeat 50%;
background-size: 100%;
padding: 110px 0 144px;
position: relative;
a {
text-decoration: none;
}
.top {
text-align: center;
.header {
height: 44px;
line-height: 44px;
.logo {
height: 44px;
vertical-align: top;
margin-right: 16px;
border-style: none;
}
.title {
font-size: 33px;
color: rgba(0, 0, 0, 0.85);
font-family: Avenir, 'Helvetica Neue', Arial, Helvetica, sans-serif;
font-weight: 600;
position: relative;
top: 2px;
}
}
.desc {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
margin-top: 12px;
margin-bottom: 40px;
}
}
.main {
min-width: 260px;
width: 368px;
margin: 0 auto;
}
}
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<a-menu
:style="{ left: position.left, top: position.top }"
class="contextmenu"
v-show="visible"
@click="handleClick"
:selectedKeys="[]"
>
<a-menu-item :key="item.key" v-for="item in itemList">
<a-icon role="menuitemicon" v-if="item.icon" :type="item.icon" />{{ item.text }}
</a-menu-item>
</a-menu>
</template>
<script>
export default {
name: 'Contextmenu',
props: {
visible: {
type: Boolean,
required: false,
default: false,
},
position: {
type: Object,
required: false,
default: () => ({ left: '0px', top: '0px' }),
},
itemList: {
type: Array,
required: true,
default: () => [],
},
},
created() {
window.addEventListener('mousedown', this.closeMenu)
},
methods: {
closeMenu(e) {
if (!e.target.closest('.contextmenu')) {
this.$emit('update:visible', false)
}
},
handleClick({ key }) {
this.$emit('select', key)
this.$emit('update:visible', false)
},
},
}
</script>
<style lang="less" scoped>
.contextmenu {
position: fixed;
z-index: 999;
// border: 1px solid #9e9e9e;
border-radius: 4px;
box-shadow: 2px 2px 10px #aaaaaa !important;
}
.contextmenu /deep/ .ant-menu-item:hover {
// background: @primary-color;
background: #f2f2f2;
// color: white;
}
</style>

View File

@ -0,0 +1,130 @@
import Menu from 'ant-design-vue/es/menu'
import { mapState } from 'vuex'
const { Item, SubMenu } = Menu
export default {
name: 'SideMenu',
props: {
collapsed: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
openKeys: [],
selectedKeys: [],
cachedOpenKeys: [],
}
},
mounted() {
this.updateMenu()
},
computed: {
rootSubmenuKeys() {
return this.menu.map(item => item.path)
},
...mapState({
menu: state => state.user.menu,
}),
},
watch: {
$route: function() {
this.updateMenu()
},
collapsed: function(newValue) {
this.openKeys = newValue ? [] : this.cachedOpenKeys
},
},
methods: {
onOpenChange(openKeys) {
const latestOpenKey = openKeys.find(key => this.openKeys.indexOf(key) === -1)
if (this.rootSubmenuKeys.indexOf(latestOpenKey) === -1) {
this.openKeys = openKeys
} else {
this.openKeys = latestOpenKey ? [latestOpenKey] : []
}
},
updateMenu() {
const routes = this.$route.matched.concat()
this.selectedKeys = [routes.pop().path]
// 如果父路由有redirectDefaultChild参数, 说明必须让父路由高亮, 父路由的上级展开
if (routes[routes.length - 1].meta.redirectDefaultChild) {
this.selectedKeys = [routes.pop().path]
}
this.cachedOpenKeys = routes.map(item => item.path)
this.openKeys = this.collapsed ? [] : this.cachedOpenKeys
// setTimeout(() => {
// document
// .querySelector(".side-menu .ant-menu-item-selected")
// ?.closest(".side-menu>[role='menuitem']")
// .scrollIntoView();
// }, 1000);
},
// render
renderItem(menu) {
if (menu.hidden) {
return null
} else {
return menu.children && !menu.alwaysShow && !menu.meta.redirectDefaultChild
? this.renderSubMenu(menu)
: this.renderMenuItem(menu)
}
},
renderMenuItem(menu) {
let menuItem
if (menu.meta?.url) {
menuItem = (
<a href={menu.meta.url} target="_blank">
{this.renderIcon(menu.meta.icon)}
<span>{menu.meta.title}</span>
</a>
)
} else {
menuItem = (
<RouterLink to={{ path: menu.path }}>
{this.renderIcon(menu.meta.icon)}
<span>{menu.meta.title}</span>
</RouterLink>
)
}
return <Item key={menu.path}>{menuItem}</Item>
},
renderSubMenu(menu) {
return (
<SubMenu key={menu.path}>
<span slot="title">
{this.renderIcon(menu.meta.icon)}
<span>{menu.meta.title}</span>
</span>
{menu.children.map(item => this.renderItem(item))}
</SubMenu>
)
},
renderIcon(icon) {
if (!icon) {
return null
}
return <a-icon type={icon} />
},
},
render() {
const menuTree = this.menu.map(item => {
return this.renderItem(item)
})
return (
<Menu
theme={this.$store.state.app.navTheme}
mode="inline"
vModel={this.selectedKeys}
openKeys={this.openKeys}
on={{ openChange: this.onOpenChange }}>
{menuTree}
</Menu>
)
},
}

View File

@ -0,0 +1,13 @@
export default {
primaryColor: '#1890FF', // primary color of ant design
navTheme: 'dark',
multiTab: true, // 默认多页签模式
// fixedHeader: false, // sticky header
// fixSiderbar: false, // sticky siderbar
storageOptions: {
namespace: 'vuejs__', // key prefix
name: 'ls', // name variable Vue.[ls] or this.[$ls],
storage: 'local', // storage name session, local, memory
},
}

48
src/main.js Normal file
View File

@ -0,0 +1,48 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import Storage from 'vue-ls'
import './utils/lazy/lazyAntd'
import './assets/icons'
import defaultSettings from '@/config/defaultSettings'
import auth from '@/utils/permission'
/**
* If you don't want to use mock-server
* you want to use MockJs for mock api
* you can execute: mockXHR()
*
* Currently MockJs will be used in the production environment,
* please remove it before going online ! ! !
*/
if (process.env.NODE_ENV === 'production') {
const { mockXHR } = require('../mock')
mockXHR()
}
Vue.use(Storage, defaultSettings.storageOptions)
Vue.directive('auth', auth)
new Vue({
router,
store,
mounted() {
// store.commit(
// "app/toggleFixedHeader",
// Vue.ls.get("fixedHeader", defaultSettings.fixedHeader)
// );
// store.commit(
// "app/toggleFixedSiderbar",
// Vue.ls.get("fixedSiderbar", defaultSettings.fixSiderbar)
// );
store.commit('app/toggleColor', Vue.ls.get('primaryColor', defaultSettings.primaryColor))
store.commit('user/setToken', Vue.ls.get('token'))
store.commit('app/toggleNavTheme', Vue.ls.get('navTheme', defaultSettings.navTheme))
store.commit('app/setMultiTab', Vue.ls.get('multiTab', defaultSettings.multiTab))
},
render: h => h(App),
}).$mount('#app')

View File

@ -0,0 +1,52 @@
export function generateIndexRouter(data) {
let indexRouter = [
{
path: '/',
name: 'root',
component: () =>
import(/* webpackChunkName: "Layout" */ '@/components/layouts/GlobalLayout.vue'),
redirect: '/home',
children: generateChildRouters(data),
},
{
path: '*',
redirect: '/404',
hidden: true,
},
]
return indexRouter
}
// 生成嵌套路由(子路由)
function generateChildRouters(data) {
const routers = []
for (const item of data) {
let componentPath = ''
if (item.component.indexOf('layouts') >= 0) {
componentPath = 'components/' + item.component
} else {
componentPath = 'views/' + item.component
}
let menu = {
path: item.path,
name: item.name,
// component: resolve => require(['@/' + componentPath + '.vue'], resolve),
component: () => import(`@/${componentPath}.vue`),
redirect: item.redirect || null,
meta: { ...item.meta },
}
if (item.alwaysShow) {
menu.component = () => import('@/components/layouts/SideTabsLayout.vue')
menu.redirect = null
}
if (item.meta.url) {
menu.component = null
}
if (item.children?.length > 0 && !item.alwaysShow) {
// if (item.children?.length > 0) {
menu.children = [...generateChildRouters(item.children)]
}
routers.push(menu)
}
return routers
}

124
src/router/index.js Normal file
View File

@ -0,0 +1,124 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import store from '@/store'
import { generateIndexRouter } from './generateIndexRouter'
import UserLayout from '@/components/layouts/UserLayout'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false })
// hack router push callback
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location, onResolve, onReject) {
if (onResolve || onReject) return originalPush.call(this, location, onResolve, onReject)
return originalPush.call(this, location).catch(err => err)
}
Vue.use(VueRouter)
const whiteList = ['/user/login', '/user/register', '/user/register-result', '/user/alteration']
const constantRoutes = [
{
path: '/user',
component: UserLayout,
redirect: '/user/login',
hidden: true,
children: [
{
path: '/user/login',
name: 'login',
component: () => import(/* webpackChunkName: "user" */ '@/views/user/Login'),
},
],
},
{
path: '/404',
component: () => import(/* webpackChunkName: "404" */ '@/views/exception/404'),
},
]
const router = new VueRouter({
routes: constantRoutes,
// scrollBehavior(to, from, savedPosition) {
// if (savedPosition && to.meta.keepAlive) {
// return savedPosition
// } else {
// return {
// x: 0,
// y: 0,
// }
// }
// },
})
let asyncRoutes = []
router.beforeEach((to, from, next) => {
NProgress.start()
/**
* 1.有token
* |-- 1.1 登录页 ==> 跳转首页
* |-- 1.2 非登录页 ==> 判断是否有权限菜单
* |-- 1.2.1 没有权限菜单 ==> 获取权限, 生成动态路由, 获取用户信息, 放行
* |-- 1.2.2 有权限菜单 ==> 放行
* 2. 没有token
* |-- 2.1 在白名单 ==> 放行
* |-- 2.2 不在白名单 ==> 跳转登录页
*/
if (store.state.user.token || Vue.ls.get('token')) {
console.log('打印', Vue.ls.get('token'))
/* has token */
hasToken(to, from, next)
} else {
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
next({ path: '/user/login' })
}
}
})
function hasToken(to, from, next) {
if (to.path === '/user/login') {
next({ path: '/home' })
} else {
if (!store.state.user.menu[0]) {
generateAsyncRouters(to, from, next)
} else {
next()
}
}
}
function generateAsyncRouters(to, from, next) {
store
.dispatch('user/getPermissionList')
.then(menu => {
asyncRoutes = generateIndexRouter(menu)
router.addRoutes(asyncRoutes)
store.dispatch('user/getUserInfo')
next({ path: to.path })
})
.catch(err => {
console.log(err)
Vue.prototype.$error({
title: '请求用户信息失败',
destroyOnClose: true,
onOk: function() {
if (from.path === '/user/login') {
store.commit('user/setToken', '')
window.location.reload()
} else {
store.dispatch('user/logout')
}
},
})
})
}
router.afterEach(() => {
NProgress.done()
})
export default router

17
src/store/index.js Normal file
View File

@ -0,0 +1,17 @@
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import app from './modules/app'
Vue.use(Vuex)
export default new Vuex.Store({
state: {},
mutations: {},
actions: {},
modules: {
user: { namespaced: true, ...user },
app: { namespaced: true, ...app },
},
})

48
src/store/modules/app.js Normal file
View File

@ -0,0 +1,48 @@
import Vue from 'vue'
import { updateTheme } from '@/components/SettingDrawer/settingConfig'
const app = {
state: {
// fixedHeader: true,
// fixSiderbar: true,
primaryColor: '',
multiTab: true,
navTheme: 'dark',
},
mutations: {
// toggleFixedHeader: (state, fixed) => {
// Vue.ls.set("fixedHeader", fixed);
// state.fixedHeader = fixed;
// },
// toggleFixedSiderbar: (state, fixed) => {
// Vue.ls.set("fixedSiderbar", fixed);
// state.fixSiderbar = fixed;
// },
toggleColor(state, color) {
Vue.ls.set('primaryColor', color)
state.primaryColor = color
},
setMultiTab(state, multiTab) {
Vue.ls.set('multiTab', multiTab)
state.multiTab = multiTab
},
toggleNavTheme(state, navTheme) {
Vue.ls.set('navTheme', navTheme)
state.navTheme = navTheme
},
},
actions: {
// toggleFixedHeader({ commit }, fixedHeader) {
// commit("toggleFixedHeader", fixedHeader);
// },
// toggleFixedSiderbar({ commit }, fixSiderbar) {
// commit("toggleFixedSiderbar", fixSiderbar);
// },
toggleColor({ commit }, color) {
updateTheme(color)
commit('toggleColor', color)
},
},
}
export default app

119
src/store/modules/user.js Normal file
View File

@ -0,0 +1,119 @@
import Vue from 'vue'
import { getUserInfo, getUserPermission } from '@/api/user'
// 递归添加每级路由到mapMenu根目录, 并添加父路由
function handleMenu(mockMenu = [], mapMenu = {}, parent) {
for (const item of mockMenu) {
item.parent = parent
mapMenu[item.path] = item
if (item.children) {
handleMenu(item.children, mapMenu, item)
}
}
return mapMenu
}
let user = {
state: {
isAdmin: false,
username: '',
avatar: '',
info: {},
token: '',
menu: [], // 菜单
buttonAuth: [], // 权限: 新增/删除/编辑, 按钮
},
getters: {
mapMenu: state => {
if (!state.menu[0]) {
return []
}
return handleMenu(state.menu)
},
},
mutations: {
setToken(state, value) {
state.token = value
Vue.ls.set('token', value)
},
// 保存用户菜单
saveMenuList(state, value) {
state.menu = value
},
saveButtonAuth(state, value) {
let actionList = value.map(i => i.action)
state.buttonAuth = actionList
// 把按钮/权限存在section, 退出登录时记得清空
sessionStorage.setItem('buttonAuth', JSON.stringify(actionList))
},
saveIsAdmin(state, value) {
state.isAdmin = value
if (value) {
state.buttonAuth.unshift('admin')
}
},
saveUserName(state, value) {
state.username = value
},
saveAvatar(state, value) {
state.avatar = value
},
saveInfo(state, value) {
state.info = value
},
},
actions: {
logout({ commit }) {
return new Promise(resolve => {
commit('setToken', '')
commit('saveMenuList', [])
window.sessionStorage.clear()
window.location.reload()
resolve()
})
},
// 获取用户权限
async getPermissionList({ commit }) {
const { errcode, data } = await getUserPermission()
if (errcode === 0 && data) {
const { menu, buttonAuth } = data
// 如果二菜单全部是隐藏的, 那么一级菜单也隐藏
if (menu && menu.length > 0) {
menu.forEach(item => {
if (item['children']) {
let hasChildrenMenu = item['children'].filter(i => {
return !i.hidden || i.hidden == false
})
if (hasChildrenMenu == null || hasChildrenMenu.length == 0) {
item['hidden'] = true
}
}
})
}
commit('saveMenuList', menu)
commit('saveButtonAuth', buttonAuth)
return new Promise(resolve => {
resolve(menu)
})
}
},
async getUserInfo({ commit }) {
const { errcode, data } = await getUserInfo()
if (errcode === 0 && data) {
let { isAdmin, username, avatar, info } = data
commit('saveIsAdmin', isAdmin)
commit('saveUserName', username)
commit('saveAvatar', avatar)
commit('saveInfo', info)
return new Promise(resolve => resolve())
}
},
},
}
export default user

67
src/utils/lazy/icons.js Normal file
View File

@ -0,0 +1,67 @@
// 注意: 路径分Outline, Fill, 和 twotone
// bell
export { default as BellOutline } from '@ant-design/icons/lib/outline/BellOutline'
// setting
export { default as SettingOutline } from '@ant-design/icons/lib/outline/SettingOutline'
// tool
export { default as ToolOutline } from '@ant-design/icons/lib/outline/ToolOutline'
// cluster
export { default as ClusterOutline } from '@ant-design/icons/lib/outline/ClusterOutline'
// logout
export { default as LogoutOutline } from '@ant-design/icons/lib/outline/LogoutOutline'
// reload
export { default as ReloadOutline } from '@ant-design/icons/lib/outline/ReloadOutline'
// arrow-left
export { default as ArrowLeftOutline } from '@ant-design/icons/lib/outline/ArrowLeftOutline'
// arrow-right
export { default as ArrowRightOutline } from '@ant-design/icons/lib/outline/ArrowRightOutline'
// close
export { default as CloseOutline } from '@ant-design/icons/lib/outline/CloseOutline'
// check
export { default as CheckOutline } from '@ant-design/icons/lib/outline/CheckOutline'
// user
export { default as UserOutline } from '@ant-design/icons/lib/outline/UserOutline'
// lock
export { default as LockOutline } from '@ant-design/icons/lib/outline/LockOutline'
// smile
export { default as SmileOutline } from '@ant-design/icons/lib/outline/SmileOutline'
// mobile
export { default as MobileOutline } from '@ant-design/icons/lib/outline/MobileOutline'
// mail
export { default as MailOutline } from '@ant-design/icons/lib/outline/MailOutline'
// wechat
export { default as WechatOutline } from '@ant-design/icons/lib/outline/WechatOutline'
// dingding
export { default as DingdingOutline } from '@ant-design/icons/lib/outline/DingdingOutline'
// menu-fold
export { default as MenuFoldOutline } from '@ant-design/icons/lib/outline/MenuFoldOutline'
// menu-unfold
export { default as MenuUnfoldOutline } from '@ant-design/icons/lib/outline/MenuUnfoldOutline'
// info-circle *
export { default as InfoCircleFill } from '@ant-design/icons/lib/fill/InfoCircleFill'
// check-circle *
export { default as CheckCircleOutline } from '@ant-design/icons/lib/outline/CheckCircleOutline'
export { default as CheckCircleFill } from '@ant-design/icons/lib/fill/CheckCircleFill'
// close-circle *
export { default as CloseCircleOutline } from '@ant-design/icons/lib/outline/CloseCircleOutline'
export { default as CloseCircleFill } from '@ant-design/icons/lib/fill/CloseCircleFill'
// exclamation-circle *
export { default as ExclamationCircleFill } from '@ant-design/icons/lib/fill/ExclamationCircleFill'
// loading
export { default as LoadingOutline } from '@ant-design/icons/lib/outline/LoadingOutline'
// question-circle *
export { default as QuestionCircleOutline } from '@ant-design/icons/lib/outline/QuestionCircleOutline'
export { default as QuestionCircleFill } from '@ant-design/icons/lib/fill/QuestionCircleFill'
// home
export { default as HomeOutline } from '@ant-design/icons/lib/outline/HomeOutline'
// github
export { default as GithubOutline } from '@ant-design/icons/lib/outline/GithubOutline'
// table
export { default as TableOutline } from '@ant-design/icons/lib/outline/TableOutline'
// profile
export { default as ProfileOutline } from '@ant-design/icons/lib/outline/ProfileOutline'
// gold
export { default as GoldOutline } from '@ant-design/icons/lib/outline/GoldOutline'
// dashboard
export { default as DashboardOutline } from '@ant-design/icons/lib/outline/DashboardOutline'

View File

@ -0,0 +1,71 @@
import Vue from 'vue'
// base library
import {
ConfigProvider,
Layout,
Input,
Button,
Switch,
Checkbox,
Form,
Row,
Col,
Modal,
Tabs,
Icon,
Popover,
Dropdown,
List,
Avatar,
Spin,
Menu,
Drawer,
Tooltip,
Alert,
Tag,
Divider,
Progress,
Result,
message,
notification,
Space,
} from 'ant-design-vue'
Vue.use(ConfigProvider)
Vue.use(Layout)
Vue.use(Input)
Vue.use(Button)
Vue.use(Switch)
Vue.use(Checkbox)
Vue.use(Form)
Vue.use(Row)
Vue.use(Col)
Vue.use(Modal)
Vue.use(Tabs)
Vue.use(Icon)
Vue.use(Popover)
Vue.use(Dropdown)
Vue.use(List)
Vue.use(Avatar)
Vue.use(Spin)
Vue.use(Menu)
Vue.use(Drawer)
Vue.use(Tooltip)
Vue.use(Alert)
Vue.use(Tag)
Vue.use(Divider)
Vue.use(Progress)
Vue.use(Result)
Vue.use(Space)
Vue.prototype.$confirm = Modal.confirm
Vue.prototype.$message = message
Vue.prototype.$notification = notification
Vue.prototype.$info = Modal.info
Vue.prototype.$success = Modal.success
Vue.prototype.$error = Modal.error
Vue.prototype.$warning = Modal.warning
process.env.NODE_ENV !== 'production' &&
console.warn('[jeecg-boot-vue] NOTICE: Antd use lazy-load.')

28
src/utils/permission.js Normal file
View File

@ -0,0 +1,28 @@
import store from '@/store'
const disableClickFn = event => {
event && event.stopImmediatePropagation()
}
export default {
inserted(el, binding) {
// if (store.state.user.isAdmin) return
const { value } = binding
const buttonAuth = store.state.user.buttonAuth
if (buttonAuth && buttonAuth instanceof Array && value.length > 0) {
const permissionRoles = value
const hasPermission = buttonAuth.some(button => {
return permissionRoles.includes(button)
})
if (!hasPermission) {
// el.parentNode && el.parentNode.removeChild(el)
el.setAttribute('disabled', 'disabled')
el.classList.add('permission-disabled')
el.addEventListener('click', disableClickFn, true)
}
} else {
throw new Error(`need roles! Like v-auth="['add','editor']"`)
}
},
}

44
src/utils/request.js Normal file
View File

@ -0,0 +1,44 @@
import axios from 'axios'
import store from '@/store'
import notification from 'ant-design-vue/es/notification'
const axiosInstance = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL,
timeout: 6000,
})
const errorHandler = err => {
notification.error({
message: '请求错误',
description: err.message,
})
return { err }
}
axiosInstance.interceptors.request.use(
config => {
// 头部增加 token
if (store.state.user.token) {
config.headers.Authorization = 'Bearer ' + store.state.user.token
}
return config
},
err => {
return Promise.reject(err)
}
)
axiosInstance.interceptors.response.use(response => {
// 响应没有data属性说明网络请求失败
if (!response.data) {
return response
}
if (response.data.errcode) {
// 如果response.data.errcode有值并且不为0的时候返回一个包含错误信息的对象
return errorHandler({ message: response.data.errmsg })
}
return response.data
}, errorHandler)
export default axiosInstance

10
src/views/About.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<script>
export default {
name: 'About',
}
</script>

18
src/views/Home.vue Normal file
View File

@ -0,0 +1,18 @@
<template>
<div style="height: 1000px">
<p><svg-icon style="font-size: 18px; color: pink" icon-class="develop" />首页(内容区滚动)</p>
<a-input placeholder="Basic usage" style="width: 200px" />
<h3 style="margin-top: 20px">按钮权限控制</h3>
<a-button v-auth="['admin']">管理员</a-button>
<a-button v-auth="['user:add']" style="margin-left: 20px">添加用户</a-button>
<a-button v-auth="['user:edit']" style="margin-left: 20px">编辑用户</a-button>
</div>
</template>
<script>
export default {
name: 'Home',
}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>个人中心页</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,24 @@
<template>
<div>
<a-input></a-input>
<p v-for="i in 1000" :key="i">基本设置</p>
</div>
</template>
<script>
export default {
mounted() {
// document.getElementById(this.$route.path).addEventListener('scroll', () => {
// console.log('')
// console.log(document.getElementById(this.$route.path).scrollTop)
// })
},
beforeDestroy() {
// console.log('')
// // console.log(document.getElementById(this.$route.path).scrollTop)
// this.$attrs.updateScrollTop(this.$route.path)
},
}
</script>
<style></style>

View File

@ -0,0 +1,12 @@
<template>
<div>
<a-input></a-input>
<p v-for="i in 1000" :key="i">账号绑定</p>
</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>个性化设置</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>最新消息通知</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>安全设置</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,18 @@
<template>
<a-result status="404" title="404" sub-title="Sorry, the page you visited does not exist.">
<template #extra>
<a-button type="primary" @click="toHome"> Back Home </a-button>
</template>
</a-result>
</template>
<script>
export default {
name: 'Exception404',
methods: {
toHome() {
this.$router.push({ path: '/' })
},
},
}
</script>

View File

@ -0,0 +1,11 @@
<template>
<div>这是标准列表页面</div>
</template>
<script>
export default {
name: 'StandardList',
}
</script>
<style></style>

View File

@ -0,0 +1,11 @@
<template>
<div>搜索列表三级菜单页面</div>
</template>
<script>
export default {
name: 'TableList',
}
</script>
<style></style>

View File

@ -0,0 +1,14 @@
<template>
<div>
<a-input placeholder="搜索" style="width: 200px" />
<a-row v-for="i in 5" :key="i">
<router-link :to="{ path: $route.path + '/' + i }">模板{{ i }}</router-link>
</a-row>
</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>{{ $route.meta.title }}</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,13 @@
<template>
<div>一级菜单页面</div>
</template>
<script>
export default {
// mounted() {
// console.log(this.$router.getRoutes());
// }
}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>高级详情页<a-input placeholder="Basic usage" /></div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>基础详情页<a-input placeholder="Basic usage" /></div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>tab1<a-input placeholder="Basic usage" /></div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,17 @@
<template>
<div>
tab2
{{ Array(3000).fill('t2').join(' ') }}
</div>
</template>
<script>
export default {
beforeDestroy() {
// console.log("tab2");
// this.$attrs.updateScrollTop("2");
},
}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>tab3<a-input placeholder="Basic usage" /></div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,9 @@
<template>
<div>tab4</div>
</template>
<script>
export default {}
</script>
<style></style>

View File

@ -0,0 +1,70 @@
<template>
<div style="display: flex">
<a-tabs
v-model="activeKey"
tab-position="left"
style="flex: none"
:class="$store.state.app.multiTab ? 'ant-tabs-top-tabs' : 'ant-tabs-no-top-tabs'"
>
<!-- @change="onChange" -->
<a-tab-pane key="1" tab="Tab1"></a-tab-pane>
<a-tab-pane key="2" tab="Tab2"></a-tab-pane>
<a-tab-pane key="3" tab="Tab3"></a-tab-pane>
<a-tab-pane key="4" tab="Tab4"></a-tab-pane>
</a-tabs>
<div
ref="tabContainer"
id="tabContainer"
:class="
$store.state.app.multiTab ? 'router-wrapper-2-top-tabs' : 'router-wrapper-2-no-top-tabs'
"
style="backgroun-color: pink; flex: auto"
>
<component :is="'Tab' + activeKey"></component>
<!-- :updateScrollTop="updateScrollTop" -->
</div>
</div>
</template>
<script>
import Tab1 from '@/views/sideTabs/components/Tab1'
import Tab2 from '@/views/sideTabs/components/Tab2'
import Tab3 from '@/views/sideTabs/components/Tab3'
import Tab4 from '@/views/sideTabs/components/Tab4'
export default {
name: 'SideTab',
components: {
Tab1,
Tab2,
Tab3,
Tab4,
},
data() {
return {
activeKey: '1',
scrollTop: {
Tab1: 0,
Tab2: 0,
Tab3: 0,
Tab4: 0,
},
}
},
// methods: {
// onChange(activeKey) {
// console.log("" + activeKey);
// this.activeKey = activeKey;
// console.log(this.scrollTop["Tab" + activeKey]);
// this.$refs.tabContainer.scrollTop = this.scrollTop["Tab" + activeKey];
// },
// updateScrollTop(activeKey) {
// console.log(document.getElementById("tabContainer").scrollTop);
// this.scrollTop["Tab" + activeKey] = this.$refs.tabContainer.scrollTop;
// }
// }
}
</script>
<style lang="less" scoped>
@import '~@/assets/less/common';
.layout-content-height();
</style>

View File

@ -0,0 +1,9 @@
<template>
<div>{{ $route.meta.title }}</div>
</template>
<script>
export default {}
</script>
<style></style>

394
src/views/user/Login.vue Normal file
View File

@ -0,0 +1,394 @@
<template>
<div class="main">
<a-form class="user-layout-login" :form="form" @submit="handleSubmit">
<a-tabs
:activeKey="customActiveKey"
:tabBarStyle="{ textAlign: 'center', borderBottom: 'unset' }"
@change="handleTabClick"
>
<a-tab-pane key="tab1" tab="账号密码登录">
<a-alert
v-if="isLoginError"
type="error"
showIcon
style="margin-bottom: 24px"
message="账户或密码错误admin/ant.design )"
/>
<a-form-item>
<a-input
size="large"
type="text"
placeholder="账户: admin 或者 user"
v-decorator="[
'username',
{
rules: [
{ required: true, message: '请输入帐户名或邮箱地址' },
{ validator: handleUsernameOrEmail },
],
validateTrigger: 'change',
},
]"
>
<a-icon slot="prefix" type="user" :style="{ color: 'rgba(0,0,0,.25)' }" />
</a-input>
</a-form-item>
<a-form-item>
<a-input-password
size="large"
placeholder="密码: admin or user"
v-decorator="[
'password',
{
rules: [{ required: true, message: '请输入密码' }],
validateTrigger: 'blur',
},
]"
>
<a-icon slot="prefix" type="lock" :style="{ color: 'rgba(0,0,0,.25)' }" />
</a-input-password>
</a-form-item>
<a-row :gutter="0">
<a-col :span="16">
<a-form-item>
<a-input
v-decorator="[
'inputCode',
{ rules: [{ required: true, message: '请输入验证码!' }] },
]"
size="large"
type="text"
placeholder="请输入验证码"
>
<a-icon slot="prefix" type="smile" :style="{ color: 'rgba(0,0,0,.25)' }" />
</a-input>
</a-form-item>
</a-col>
<a-col :span="8" style="text-align: right">
<img
v-if="requestCodeSuccess"
style="margin-top: 2px"
:src="randCodeImage"
@click="handleChangeCheckCode"
/>
<img
v-else
style="margin-top: 2px"
src="@/assets/checkcode.png"
@click="handleChangeCheckCode"
/>
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="tab2" tab="手机号登录">
<a-form-item>
<a-input
size="large"
type="text"
placeholder="手机号"
v-decorator="[
'mobile',
{
rules: [
{
required: true,
pattern: /^1[34578]\d{9}$/,
message: '请输入正确的手机号',
},
],
validateTrigger: 'change',
},
]"
>
<a-icon slot="prefix" type="mobile" :style="{ color: 'rgba(0,0,0,.25)' }" />
</a-input>
</a-form-item>
<a-row :gutter="16">
<a-col class="gutter-row" :span="16">
<a-form-item>
<a-input
size="large"
type="text"
placeholder="验证码"
v-decorator="[
'captcha',
{
rules: [{ required: true, message: '请输入验证码' }],
validateTrigger: 'blur',
},
]"
>
<a-icon slot="prefix" type="mail" :style="{ color: 'rgba(0,0,0,.25)' }" />
</a-input>
</a-form-item>
</a-col>
<a-col class="gutter-row" :span="8">
<a-button
class="getCaptcha"
tabindex="-1"
:disabled="state.smsSendBtn"
@click.stop.prevent="getCaptcha"
v-text="state.smsSendBtn ? state.time + ' s' : '获取验证码'"
></a-button>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
<a-form-item>
<a-checkbox v-decorator="['rememberMe', { valuePropName: 'checked' }]">自动登录</a-checkbox>
<!-- <router-link
:to="{ name: 'recover', params: { user: 'aaa' } }"
class="forge-password"
style="float: right"
>忘记密码</router-link
> -->
</a-form-item>
<a-form-item style="margin-top: 24px">
<a-button
size="large"
type="primary"
htmlType="submit"
class="login-button"
:loading="state.loginBtn"
:disabled="state.loginBtn"
>确定</a-button
>
</a-form-item>
<div class="user-login-other">
<span>其他登录方式</span>
<a @click="onThirdLogin('github')" title="github"
><a-icon class="item-icon" type="github"></a-icon
></a>
<a @click="onThirdLogin('wechat_enterprise')" title="企业微信"
><a-icon class="item-icon" type="wechat"></a-icon
></a>
<a @click="onThirdLogin('dingtalk')" title="钉钉"
><a-icon class="item-icon" type="dingding"></a-icon
></a>
<!-- <router-link class="register" :to="{ name: 'register' }">注册账户</router-link> -->
</div>
</a-form>
</div>
</template>
<script>
import md5 from 'md5'
import { mapActions } from 'vuex'
import { login } from '@/api/user'
export default {
data() {
return {
requestCodeSuccess: false,
randCodeImage: '',
customActiveKey: 'tab1',
loginBtn: false,
isLoginError: false,
form: this.$form.createForm(this),
state: {
time: 60,
loginBtn: false,
// login type: 0 email, 1 username, 2 telephone
loginType: 0,
smsSendBtn: false,
},
}
},
methods: {
...mapActions(['Login', 'Logout']),
// handler
handleUsernameOrEmail(rule, value, callback) {
const { state } = this
const regex = /^([a-zA-Z0-9_-])+@([a-zA-Z0-9_-])+((\.[a-zA-Z0-9_-]{2,3}){1,2})$/
if (regex.test(value)) {
state.loginType = 0
} else {
state.loginType = 1
}
callback()
},
handleTabClick(key) {
this.customActiveKey = key
// this.form.resetFields()
},
handleChangeCheckCode() {},
onThirdLogin() {},
handleSubmit(e) {
e.preventDefault()
const {
form: { validateFields },
state,
customActiveKey,
// Login
} = this
state.loginBtn = true
const validateFieldsKey =
customActiveKey === 'tab1'
? ['username', 'password', 'rememberMe']
: ['mobile', 'captcha', 'rememberMe']
validateFields(validateFieldsKey, { force: true }, (err, values) => {
if (!err) {
console.log('login form', values)
let loginParams = { ...values }
if (this.customActiveKey === 'tab1') {
delete loginParams.username
loginParams[!state.loginType ? 'email' : 'username'] = values.username
loginParams.password = md5(values.password)
}
login(loginParams)
.then(res => {
console.log(res)
if (res.errcode === 0) {
this.loginSuccess(res.data.token)
}
})
.catch(err => this.requestFailed(err))
.finally(() => {
state.loginBtn = false
})
} else {
setTimeout(() => {
state.loginBtn = false
}, 600)
}
})
},
//
getCaptcha(e) {
e.preventDefault()
const {
form: { validateFields },
state,
} = this
validateFields(['mobile'], { force: true }, (err, values) => {
if (!err) {
console.log(values)
state.smsSendBtn = true
const interval = window.setInterval(() => {
if (state.time-- <= 0) {
state.time = 60
state.smsSendBtn = false
window.clearInterval(interval)
}
}, 1000)
this.$message.loading('验证码发送中..', 3)
// const hide = this.$message.loading("..", 0);
// getSmsCaptcha({ mobile: values.mobile })
// .then(res => {
// setTimeout(hide, 2500);
// this.$notification["success"]({
// message: "",
// description:
// "" + res.result.captcha,
// duration: 8
// });
// })
// .catch(err => {
// setTimeout(hide, 1);
// clearInterval(interval);
// state.time = 60;
// state.smsSendBtn = false;
// this.requestFailed(err);
// });
}
})
},
//
loginSuccess(token) {
// check res.homePage define, set $router.push name res.homePage
// Why not enter onComplete
/*
this.$router.push({ name: 'analysis' }, () => {
console.log('onComplete')
this.$notification.success({
message: '欢迎',
description: `${timeFix()},欢迎回来`
})
})
*/
console.log('登录成功')
this.$store.commit('user/setToken', token)
this.$router.push({ path: '/' })
// 1
setTimeout(() => {
this.$notification.success({
message: '欢迎',
description: '欢迎回来',
duration: 2,
})
}, 1000)
this.isLoginError = false
},
//
requestFailed(err) {
this.isLoginError = true
this.$notification.error({
message: '错误',
description: ((err.response || {}).data || {}).message || '请求出现错误,请稍后再试',
duration: 4,
})
},
},
}
</script>
<style lang="less" scoped>
.user-layout-login {
label {
font-size: 14px;
}
.getCaptcha {
display: block;
width: 100%;
height: 40px;
}
.forge-password {
font-size: 14px;
}
button.login-button {
padding: 0 15px;
font-size: 16px;
height: 40px;
width: 100%;
}
.user-login-other {
text-align: left;
margin-top: 24px;
line-height: 22px;
.item-icon {
font-size: 24px;
color: rgba(0, 0, 0, 0.2);
margin-left: 16px;
vertical-align: middle;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.register {
float: right;
}
}
}
</style>

View File

@ -0,0 +1,87 @@
<template>
<a-form
id="components-form-demo-normal-login"
:form="form"
class="login-form"
@submit="handleSubmit"
>
<a-form-item>
<a-input
v-decorator="[
'userName',
{
rules: [{ required: true, message: 'Please input your username!' }],
},
]"
placeholder="Username"
>
<a-icon slot="prefix" type="user" style="color: rgba(0, 0, 0, 0.25)" />
</a-input>
</a-form-item>
<a-form-item>
<a-input
v-decorator="[
'password',
{
rules: [{ required: true, message: 'Please input your Password!' }],
},
]"
type="password"
placeholder="Password"
>
<a-icon slot="prefix" type="lock" style="color: rgba(0, 0, 0, 0.25)" />
</a-input>
</a-form-item>
<a-form-item>
<a-checkbox
v-decorator="[
'remember',
{
valuePropName: 'checked',
initialValue: true,
},
]"
>
Remember me
</a-checkbox>
<a class="login-form-forgot" href=""> Forgot password </a>
<a-button type="primary" html-type="submit" class="login-form-button"> Log in </a-button>
Or
<a href=""> register now! </a>
</a-form-item>
</a-form>
</template>
<script>
export default {
name: 'Login',
beforeCreate() {
this.form = this.$form.createForm(this, { name: 'normal_login' })
},
methods: {
handleSubmit(e) {
e.preventDefault()
this.form.validateFields((err, values) => {
if (!err) {
console.log('Received values of form: ', values)
}
})
},
},
}
</script>
<style>
#components-form-demo-normal-login.login-form {
max-width: 300px;
position: fixed;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
}
#components-form-demo-normal-login .login-form-forgot {
float: right;
}
#components-form-demo-normal-login .login-form-button {
width: 100%;
}
</style>

16
test/test.js Normal file
View File

@ -0,0 +1,16 @@
var testCase = require("mocha").describe;
var pre = require("mocha").before;
var assertions = require("mocha").it;
var assert = require("chai").assert;
testCase("Array", function() {
pre(function() {
// ...
});
testCase("#indexOf()", function() {
assertions("should return -1 when not present", function() {
assert.equal([1, 2, 3].indexOf(4), -1);
});
});
});

155
vue.config.js Normal file
View File

@ -0,0 +1,155 @@
'use strict'
const path = require('path')
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
const webpack = require('webpack')
const CompressionPlugin = require('compression-webpack-plugin')
function resolve(dir) {
return path.join(__dirname, dir)
}
module.exports = {
lintOnSave: process.env.NODE_ENV !== 'production',
// 如果你不需要生产环境的 source map可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false,
configureWebpack: config => {
// 生产环境取消 console.log
if (process.env.NODE_ENV === 'production') {
config.optimization.minimizer[0].options.terserOptions.compress.drop_console = true
}
// 打包文件大小分析
if (process.env.NODE_ENV === 'production') {
config.plugins.push(new BundleAnalyzerPlugin())
}
},
chainWebpack: config => {
// 生产环境开启js\css压缩
if (process.env.NODE_ENV === 'production') {
config.plugin('compressionPlugin').use(
new CompressionPlugin({
test: /\.(js|css|less|html)$/, // 匹配文件名
threshold: 10240, // 对超过10k的数据压缩
deleteOriginalAssets: false, // 不删除源文件
})
)
}
config
.plugin('ignore')
// 忽略/moment/locale下的所有文件
.use(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/))
// 按需加载icon
config.resolve.alias.set('@ant-design/icons/lib/dist$', resolve('src/utils/lazy/icons.js'))
// 预加载, 提高首屏速度
config.plugin('preload').tap(() => [
{
rel: 'preload',
// to ignore runtime.js
// https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/config/app.js#L171
fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
include: 'initial',
},
])
config
// https://webpack.js.org/configuration/devtool/#development
.when(process.env.NODE_ENV === 'development', config =>
config.devtool('cheap-module-eval-source-map')
)
// 去掉空闲加载
// 空闲加载需要设置 /webpackChunkName: 'chunk-name'/
config.plugins.delete('prefetch')
// set svg-sprite-loader
config.module
.rule('svg')
.exclude.add(resolve('src/assets/icons'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]',
})
.end()
config.when(process.env.NODE_ENV !== 'development', config => {
config.optimization.splitChunks({
chunks: 'all',
cacheGroups: {
libs: {
name: 'chunk-libs',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'initial', // 只打包初始时依赖的第三方
},
antdesignvueUI: {
name: 'chunk-antdesignvueUI', // 单独将 antdesignvueUI 拆包
priority: 20, // 权重要大于 libs 和 app 不然会被打包进 libs 或者 app
test: /[\\/]node_modules[\\/]ant-design-vue[\\/]es(.*)/, // in order to adapt to cnpm
},
commons: {
name: 'chunk-commons',
test: resolve('src/components'), // 可自定义拓展你的规则
minChunks: 3, // 最小共用次数
priority: 5,
reuseExistingChunk: true,
},
},
})
})
},
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
// 代理转发
devServer: {
port: 3005,
// open: true, // 自动打开浏览器
overlay: {
// 编译页面显示错误
warnings: false,
errors: true,
},
// proxy: {
// ['^' + process.env.VUE_APP_API_BASE_URL]: {
// target: 'http://www.baidu.com',
// changeOrigin: true,
// pathRewrite: {
// ['^' + process.env.VUE_APP_API_BASE_URL]:''
// }
// },
// },
before: require('./mock/mock-server.js'),
},
// 样式
css: {
loaderOptions: {
less: {
// lessOptions: {
// If you are using less-loader@5 please spread the lessOptions to options directly
modifyVars: {
/* less 变量覆盖,用于自定义 ant design 主题 */
'primary-color': '#1890FF',
'link-color': '#1890FF',
'border-radius-base': '2px',
},
javascriptEnabled: true,
// },
},
},
extract: { ignoreOrder: true }, // 忽略mini-css-extract-plugin因为css引入顺序不同警告
},
}

10946
yarn.lock Normal file

File diff suppressed because it is too large Load Diff