commit c5aa2a9bd68ffb90bd3c7bfbfc7b6ef6d67e7ef8 Author: caoqianming Date: Tue Aug 10 09:59:04 2021 +0800 first commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6e51dee --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.vue linguist-language=python diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa116e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +unpackage/dist/* +node_modules/* +deploy.sh +package-lock.json +.idea/ +.vscode/ +server/static/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..09310e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 blackholll + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4e677e --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# 简介 +基于RBAC模型权限控制的中小型应用的基础开发平台,前后端分离,后端采用django+django-rest-framework,前端采用vue+ElementUI,移动端采用uniapp+uView(可发布h5和小程序). + +JWT认证,可使用simple_history实现审计功能,支持swagger + +内置模块有组织机构\用户\角色\岗位\数据字典\文件库\定时任务 + +支持功能权限(控权到每个接口)和简单的数据权限(全部、本级及以下、同级及以下、本人等) + +## 部分截图 +![image](https://github.com/caoqianming/django-vue-admin/blob/master/img/user.png) +![image](https://github.com/caoqianming/django-vue-admin/blob/master/img/dict.png) +![image](https://github.com/caoqianming/django-vue-admin/blob/master/img/task.png) + +## 启动(以下是在windows下开发操作步骤) + + +### django后端 +定位到server文件夹 + +建立虚拟环境 `python -m venv venv` + +激活虚拟环境 `.\venv\scripts\activate` + +安装依赖包 `pip install -r requirements.txt` + +修改数据库连接 `server\settings_dev.py` + +同步数据库 `python manage.py migrate` + +可导入初始数据 `python manage.py loaddata db.json` 或直接使用sqlite数据库(超管账户密码均为admin) + +创建超级管理员 `python manage.py createsuperuser` + +运行服务 `python manage.py runserver 8000` + +### vue前端 +定位到client文件夹 + +安装node.js + +安装依赖包 `npm install --registry=https://registry.npm.taobao.org` + +运行服务 `npm run dev` + +### nginx +修改nginx.conf + +``` +listen 8012 +location /media { + proxy_pass http://localhost:8000; +} +location / { + proxy_pass http://localhost:9528; +} +``` + +运行nginx.exe + +### 运行 +打开localhost:8012即可访问 + +接口文档 localhost:8000/docs + +后台地址 localhost:8000/admin + +### docker-compose 方式运行 + +前端 `./client` 和后端 `./server` 目录下都有Dockerfile,如果需要单独构建镜像,可以自行构建。 + +这里主要说docker-compose启动这种方式。 + +按照注释修改docker-compose.yml文件。里面主要有两个服务,一个是`backend`后端,一个是`frontend`前端。 + +默认是用开发模式跑的后端和前端。如果需要单机部署,又想用docker-compose的话,改为生产模式性能会好些。 + + +启动 +``` +cd +docker-compose up -d +``` + +启动成功后,访问端口同前面的,接口8000端口,前端8012端口,如需改动,自己改docker-compose.yml + +如果要执行里面的命令 +docker-compose exec <服务名> <命令> + +举个栗子: + +如果我要执行后端生成数据变更命令。`python manage.py makemigrations` + +则用如下语句 + +``` +docker-compose exec backend python manage.py makemigrations +``` + +### 理念 +首先得会使用django-rest-framework, 理解vue-element-admin前端方案 + +本项目采用前端路由,后端根据用户角色读取用户权限代码返回给前端,由前端进行加载(核心代码是路由表中的perms属性以及checkpermission方法) + +后端功能权限的核心代码在server/apps/system/permission.py下重写了has_permission方法, 在APIView和ViewSet中定义perms权限代码 + +数据权限因为跟具体业务有关,简单定义了几个规则,重写了has_object_permission方法;根据需要使用即可 + +### 关于定时任务 +使用celery以及django_celery_beat包实现 + +需要安装redis并在默认端口启动, 并启动worker以及beat + +进入虚拟环境并启动worker: `celery -A server worker -l info -P eventlet`, linux系统不用加-P eventlet + +进入虚拟环境并启动beat: `celery -A server beat -l info` + +### 后续 +考虑增加一个简易的工作流模块 + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6fb9ee2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3" +services: + backend: + build: ./server + ports: + - "8000:80" + environment: + # 生产的话把DJANGO_ENV这个环境变量删了 执行docker-compose build backend 重新构建下镜像 + - DJANGO_ENV=dev + volumes: + - ./server:/code + links: + - redis + frontend: + build: + context: ./client + # 生产用这个 + # dockerfile: Dockerfile + # 开发的话用这个 + dockerfile: Dockerfile_dev + ports: + - "8012:80" + redis: + image: redis + command: redis-server --appendonly yes \ No newline at end of file diff --git a/hb_client/.editorconfig b/hb_client/.editorconfig new file mode 100644 index 0000000..ea6e20f --- /dev/null +++ b/hb_client/.editorconfig @@ -0,0 +1,14 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +insert_final_newline = false +trim_trailing_whitespace = false diff --git a/hb_client/.env.development b/hb_client/.env.development new file mode 100644 index 0000000..f0aff47 --- /dev/null +++ b/hb_client/.env.development @@ -0,0 +1,14 @@ +# just a flag +ENV = 'development' + +# base api +VUE_APP_BASE_API = 'http://localhost:8000/api' + +# vue-cli uses the VUE_CLI_BABEL_TRANSPILE_MODULES environment variable, +# to control whether the babel-plugin-dynamic-import-node plugin is enabled. +# It only does one thing by converting all import() to require(). +# This configuration can significantly increase the speed of hot updates, +# when you have a large number of pages. +# Detail: https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/babel-preset-app/index.js + +VUE_CLI_BABEL_TRANSPILE_MODULES = true diff --git a/hb_client/.env.production b/hb_client/.env.production new file mode 100644 index 0000000..8994f69 --- /dev/null +++ b/hb_client/.env.production @@ -0,0 +1,6 @@ +# just a flag +ENV = 'production' + +# base api +VUE_APP_BASE_API = '' + diff --git a/hb_client/.env.staging b/hb_client/.env.staging new file mode 100644 index 0000000..a8793a0 --- /dev/null +++ b/hb_client/.env.staging @@ -0,0 +1,8 @@ +NODE_ENV = production + +# just a flag +ENV = 'staging' + +# base api +VUE_APP_BASE_API = '/stage-api' + diff --git a/hb_client/.eslintignore b/hb_client/.eslintignore new file mode 100644 index 0000000..e6529fc --- /dev/null +++ b/hb_client/.eslintignore @@ -0,0 +1,4 @@ +build/*.js +src/assets +public +dist diff --git a/hb_client/.eslintrc.js b/hb_client/.eslintrc.js new file mode 100644 index 0000000..c977505 --- /dev/null +++ b/hb_client/.eslintrc.js @@ -0,0 +1,198 @@ +module.exports = { + root: true, + parserOptions: { + parser: 'babel-eslint', + sourceType: 'module' + }, + env: { + browser: true, + node: true, + es6: true, + }, + extends: ['plugin:vue/recommended', 'eslint:recommended'], + + // add your custom rules here + //it is base on https://github.com/vuejs/eslint-config-vue + rules: { + "vue/max-attributes-per-line": [2, { + "singleline": 10, + "multiline": { + "max": 1, + "allowFirstLine": false + } + }], + "vue/singleline-html-element-content-newline": "off", + "vue/multiline-html-element-content-newline":"off", + "vue/name-property-casing": ["error", "PascalCase"], + "vue/no-v-html": "off", + 'accessor-pairs': 2, + 'arrow-spacing': [2, { + 'before': true, + 'after': true + }], + 'block-spacing': [2, 'always'], + 'brace-style': [2, '1tbs', { + 'allowSingleLine': true + }], + 'camelcase': [0, { + 'properties': 'always' + }], + 'comma-dangle': [2, 'never'], + 'comma-spacing': [2, { + 'before': false, + 'after': true + }], + 'comma-style': [2, 'last'], + 'constructor-super': 2, + 'curly': [2, 'multi-line'], + 'dot-location': [2, 'property'], + 'eol-last': 2, + 'eqeqeq': ["error", "always", {"null": "ignore"}], + 'generator-star-spacing': [2, { + 'before': true, + 'after': true + }], + 'handle-callback-err': [2, '^(err|error)$'], + 'indent': [2, 2, { + 'SwitchCase': 1 + }], + 'jsx-quotes': [2, 'prefer-single'], + 'key-spacing': [2, { + 'beforeColon': false, + 'afterColon': true + }], + 'keyword-spacing': [2, { + 'before': true, + 'after': true + }], + 'new-cap': [2, { + 'newIsCap': true, + 'capIsNew': false + }], + 'new-parens': 2, + 'no-array-constructor': 2, + 'no-caller': 2, + 'no-console': 'off', + 'no-class-assign': 2, + 'no-cond-assign': 2, + 'no-const-assign': 2, + 'no-control-regex': 0, + 'no-delete-var': 2, + 'no-dupe-args': 2, + 'no-dupe-class-members': 2, + 'no-dupe-keys': 2, + 'no-duplicate-case': 2, + 'no-empty-character-class': 2, + 'no-empty-pattern': 2, + 'no-eval': 2, + 'no-ex-assign': 2, + 'no-extend-native': 2, + 'no-extra-bind': 2, + 'no-extra-boolean-cast': 2, + 'no-extra-parens': [2, 'functions'], + 'no-fallthrough': 2, + 'no-floating-decimal': 2, + 'no-func-assign': 2, + 'no-implied-eval': 2, + 'no-inner-declarations': [2, 'functions'], + 'no-invalid-regexp': 2, + 'no-irregular-whitespace': 2, + 'no-iterator': 2, + 'no-label-var': 2, + 'no-labels': [2, { + 'allowLoop': false, + 'allowSwitch': false + }], + 'no-lone-blocks': 2, + 'no-mixed-spaces-and-tabs': 2, + 'no-multi-spaces': 2, + 'no-multi-str': 2, + 'no-multiple-empty-lines': [2, { + 'max': 1 + }], + 'no-native-reassign': 2, + 'no-negated-in-lhs': 2, + 'no-new-object': 2, + 'no-new-require': 2, + 'no-new-symbol': 2, + 'no-new-wrappers': 2, + 'no-obj-calls': 2, + 'no-octal': 2, + 'no-octal-escape': 2, + 'no-path-concat': 2, + 'no-proto': 2, + 'no-redeclare': 2, + 'no-regex-spaces': 2, + 'no-return-assign': [2, 'except-parens'], + 'no-self-assign': 2, + 'no-self-compare': 2, + 'no-sequences': 2, + 'no-shadow-restricted-names': 2, + 'no-spaced-func': 2, + 'no-sparse-arrays': 2, + 'no-this-before-super': 2, + 'no-throw-literal': 2, + 'no-trailing-spaces': 2, + 'no-undef': 2, + 'no-undef-init': 2, + 'no-unexpected-multiline': 2, + 'no-unmodified-loop-condition': 2, + 'no-unneeded-ternary': [2, { + 'defaultAssignment': false + }], + 'no-unreachable': 2, + 'no-unsafe-finally': 2, + 'no-unused-vars': [2, { + 'vars': 'all', + 'args': 'none' + }], + 'no-useless-call': 2, + 'no-useless-computed-key': 2, + 'no-useless-constructor': 2, + 'no-useless-escape': 0, + 'no-whitespace-before-property': 2, + 'no-with': 2, + 'one-var': [2, { + 'initialized': 'never' + }], + 'operator-linebreak': [2, 'after', { + 'overrides': { + '?': 'before', + ':': 'before' + } + }], + 'padded-blocks': [2, 'never'], + 'quotes': [2, 'single', { + 'avoidEscape': true, + 'allowTemplateLiterals': true + }], + 'semi': [2, 'never'], + 'semi-spacing': [2, { + 'before': false, + 'after': true + }], + 'space-before-blocks': [2, 'always'], + 'space-before-function-paren': [2, 'never'], + 'space-in-parens': [2, 'never'], + 'space-infix-ops': 2, + 'space-unary-ops': [2, { + 'words': true, + 'nonwords': false + }], + 'spaced-comment': [2, 'always', { + 'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ','] + }], + 'template-curly-spacing': [2, 'never'], + 'use-isnan': 2, + 'valid-typeof': 2, + 'wrap-iife': [2, 'any'], + 'yield-star-spacing': [2, 'both'], + 'yoda': [2, 'never'], + 'prefer-const': 2, + 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, + 'object-curly-spacing': [2, 'always', { + objectsInObjects: false + }], + 'array-bracket-spacing': [2, 'never'] + } +} diff --git a/hb_client/.gitignore b/hb_client/.gitignore new file mode 100644 index 0000000..9ad28d2 --- /dev/null +++ b/hb_client/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +node_modules/ +dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +tests/**/coverage/ + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln diff --git a/hb_client/.travis.yml b/hb_client/.travis.yml new file mode 100644 index 0000000..f4be7a0 --- /dev/null +++ b/hb_client/.travis.yml @@ -0,0 +1,5 @@ +language: node_js +node_js: 10 +script: npm run test +notifications: + email: false diff --git a/hb_client/Dockerfile b/hb_client/Dockerfile new file mode 100644 index 0000000..35534e1 --- /dev/null +++ b/hb_client/Dockerfile @@ -0,0 +1,6 @@ +FROM node:10-alpine3.9 as builder +WORKDIR /code +COPY . . +RUN npm install --registry=https://registry.npm.taobao.org && npm run build:prod +FROM nginx:1.19.2-alpine +COPY --from=builder /code/dist /usr/share/nginx/html diff --git a/hb_client/Dockerfile_dev b/hb_client/Dockerfile_dev new file mode 100644 index 0000000..6f5a6ad --- /dev/null +++ b/hb_client/Dockerfile_dev @@ -0,0 +1,7 @@ +FROM node:10-alpine3.9 +ENV NODE_ENV=development +WORKDIR /code +COPY . . +RUN npm config set sass_binary_site=https://npm.taobao.org/mirrors/node-sass &&\ + npm install --registry=https://registry.npm.taobao.org +ENTRYPOINT ["npm","run","dev:docker"] diff --git a/hb_client/LICENSE b/hb_client/LICENSE new file mode 100644 index 0000000..6151575 --- /dev/null +++ b/hb_client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-present PanJiaChen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/hb_client/README-zh.md b/hb_client/README-zh.md new file mode 100644 index 0000000..d248632 --- /dev/null +++ b/hb_client/README-zh.md @@ -0,0 +1,98 @@ +# vue-admin-template + +> 这是一个极简的 vue admin 管理后台。它只包含了 Element UI & axios & iconfont & permission control & lint,这些搭建后台必要的东西。 + +[线上地址](http://panjiachen.github.io/vue-admin-template) + +[国内访问](https://panjiachen.gitee.io/vue-admin-template) + +目前版本为 `v4.0+` 基于 `vue-cli` 进行构建,若你想使用旧版本,可以切换分支到[tag/3.11.0](https://github.com/PanJiaChen/vue-admin-template/tree/tag/3.11.0),它不依赖 `vue-cli`。 + +## Extra + +如果你想要根据用户角色来动态生成侧边栏和 router,你可以使用该分支[permission-control](https://github.com/PanJiaChen/vue-admin-template/tree/permission-control) + +## 相关项目 + +- [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin) + +- [electron-vue-admin](https://github.com/PanJiaChen/electron-vue-admin) + +- [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) + +- [awesome-project](https://github.com/PanJiaChen/vue-element-admin/issues/2312) + +写了一个系列的教程配套文章,如何从零构建后一个完整的后台项目: + +- [手摸手,带你用 vue 撸后台 系列一(基础篇)](https://juejin.im/post/59097cd7a22b9d0065fb61d2) +- [手摸手,带你用 vue 撸后台 系列二(登录权限篇)](https://juejin.im/post/591aa14f570c35006961acac) +- [手摸手,带你用 vue 撸后台 系列三 (实战篇)](https://juejin.im/post/593121aa0ce4630057f70d35) +- [手摸手,带你用 vue 撸后台 系列四(vueAdmin 一个极简的后台基础模板,专门针对本项目的文章,算作是一篇文档)](https://juejin.im/post/595b4d776fb9a06bbe7dba56) +- [手摸手,带你封装一个 vue component](https://segmentfault.com/a/1190000009090836) + +## Build Setup + +```bash +# 克隆项目 +git clone https://github.com/PanJiaChen/vue-admin-template.git + +# 进入项目目录 +cd vue-admin-template + +# 安装依赖 +npm install + +# 建议不要直接使用 cnpm 安装以来,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题 +npm install --registry=https://registry.npm.taobao.org + +# 启动服务 +npm run dev +``` + +浏览器访问 [http://localhost:9528](http://localhost:9528) + +## 发布 + +```bash +# 构建测试环境 +npm run build:stage + +# 构建生产环境 +npm run build:prod +``` + +## 其它 + +```bash +# 预览发布环境效果 +npm run preview + +# 预览发布环境效果 + 静态资源分析 +npm run preview -- --report + +# 代码格式检查 +npm run lint + +# 代码格式检查并自动修复 +npm run lint -- --fix +``` + +更多信息请参考 [使用文档](https://panjiachen.github.io/vue-element-admin-site/zh/) + +## Demo + +![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif) + +## Browsers support + +Modern browsers and Internet Explorer 10+. + +| [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | +| --------- | --------- | --------- | --------- | +| IE10, IE11, Edge| last 2 versions| last 2 versions| last 2 versions + +## License + +[MIT](https://github.com/PanJiaChen/vue-admin-template/blob/master/LICENSE) license. + +Copyright (c) 2017-present PanJiaChen diff --git a/hb_client/README.md b/hb_client/README.md new file mode 100644 index 0000000..b99f942 --- /dev/null +++ b/hb_client/README.md @@ -0,0 +1,91 @@ +# vue-admin-template + +English | [简体中文](./README-zh.md) + +> A minimal vue admin template with Element UI & axios & iconfont & permission control & lint + +**Live demo:** http://panjiachen.github.io/vue-admin-template + + +**The current version is `v4.0+` build on `vue-cli`. If you want to use the old version , you can switch branch to [tag/3.11.0](https://github.com/PanJiaChen/vue-admin-template/tree/tag/3.11.0), it does not rely on `vue-cli`** + +## Build Setup + + +```bash +# clone the project +git clone https://github.com/PanJiaChen/vue-admin-template.git + +# enter the project directory +cd vue-admin-template + +# install dependency +npm install + +# develop +npm run dev +``` + +This will automatically open http://localhost:9528 + +## Build + +```bash +# build for test environment +npm run build:stage + +# build for production environment +npm run build:prod +``` + +## Advanced + +```bash +# preview the release environment effect +npm run preview + +# preview the release environment effect + static resource analysis +npm run preview -- --report + +# code format check +npm run lint + +# code format check and auto fix +npm run lint -- --fix +``` + +Refer to [Documentation](https://panjiachen.github.io/vue-element-admin-site/guide/essentials/deploy.html) for more information + +## Demo + +![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif) + +## Extra + +If you want router permission && generate menu by user roles , you can use this branch [permission-control](https://github.com/PanJiaChen/vue-admin-template/tree/permission-control) + +For `typescript` version, you can use [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) (Credits: [@Armour](https://github.com/Armour)) + +## Related Project + +- [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin) + +- [electron-vue-admin](https://github.com/PanJiaChen/electron-vue-admin) + +- [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template) + +- [awesome-project](https://github.com/PanJiaChen/vue-element-admin/issues/2312) + +## Browsers support + +Modern browsers and Internet Explorer 10+. + +| [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | +| --------- | --------- | --------- | --------- | +| IE10, IE11, Edge| last 2 versions| last 2 versions| last 2 versions + +## License + +[MIT](https://github.com/PanJiaChen/vue-admin-template/blob/master/LICENSE) license. + +Copyright (c) 2017-present PanJiaChen diff --git a/hb_client/babel.config.js b/hb_client/babel.config.js new file mode 100644 index 0000000..ba17966 --- /dev/null +++ b/hb_client/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + presets: [ + '@vue/app' + ] +} diff --git a/hb_client/build/index.js b/hb_client/build/index.js new file mode 100644 index 0000000..0c57de2 --- /dev/null +++ b/hb_client/build/index.js @@ -0,0 +1,35 @@ +const { run } = require('runjs') +const chalk = require('chalk') +const config = require('../vue.config.js') +const rawArgv = process.argv.slice(2) +const args = rawArgv.join(' ') + +if (process.env.npm_config_preview || rawArgv.includes('--preview')) { + const report = rawArgv.includes('--report') + + run(`vue-cli-service build ${args}`) + + const port = 9526 + const publicPath = config.publicPath + + var connect = require('connect') + var serveStatic = require('serve-static') + const app = connect() + + app.use( + publicPath, + serveStatic('./dist', { + index: ['index.html', '/'] + }) + ) + + app.listen(port, function () { + console.log(chalk.green(`> Preview at http://localhost:${port}${publicPath}`)) + if (report) { + console.log(chalk.green(`> Report at http://localhost:${port}${publicPath}report.html`)) + } + + }) +} else { + run(`vue-cli-service build ${args}`) +} diff --git a/hb_client/jest.config.js b/hb_client/jest.config.js new file mode 100644 index 0000000..143cdc8 --- /dev/null +++ b/hb_client/jest.config.js @@ -0,0 +1,24 @@ +module.exports = { + moduleFileExtensions: ['js', 'jsx', 'json', 'vue'], + transform: { + '^.+\\.vue$': 'vue-jest', + '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': + 'jest-transform-stub', + '^.+\\.jsx?$': 'babel-jest' + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + }, + snapshotSerializers: ['jest-serializer-vue'], + testMatch: [ + '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' + ], + collectCoverageFrom: ['src/utils/**/*.{js,vue}', '!src/utils/auth.js', '!src/utils/request.js', 'src/components/**/*.{js,vue}'], + coverageDirectory: '/tests/unit/coverage', + // 'collectCoverage': true, + 'coverageReporters': [ + 'lcov', + 'text-summary' + ], + testURL: 'http://localhost/' +} diff --git a/hb_client/jsconfig.json b/hb_client/jsconfig.json new file mode 100644 index 0000000..ed079e2 --- /dev/null +++ b/hb_client/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/hb_client/mock/index.js b/hb_client/mock/index.js new file mode 100644 index 0000000..90e2ffe --- /dev/null +++ b/hb_client/mock/index.js @@ -0,0 +1,67 @@ +import Mock from 'mockjs' +import { param2Obj } from '../src/utils' + +import user from './user' +import table from './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). +export 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)) + } +} + +// for mock server +const responseFake = (url, type, respond) => { + return { + url: new RegExp(`${process.env.VUE_APP_BASE_API}${url}`), + type: type || 'get', + response(req, res) { + console.log('request invoke:' + req.path) + res.json(Mock.mock(respond instanceof Function ? respond(req, res) : respond)) + } + } +} + +export default mocks.map(route => { + return responseFake(route.url, route.type, route.response) +}) diff --git a/hb_client/mock/mock-server.js b/hb_client/mock/mock-server.js new file mode 100644 index 0000000..4c4cb2a --- /dev/null +++ b/hb_client/mock/mock-server.js @@ -0,0 +1,68 @@ +const chokidar = require('chokidar') +const bodyParser = require('body-parser') +const chalk = require('chalk') +const path = require('path') + +const mockDir = path.join(process.cwd(), 'mock') + +function registerRoutes(app) { + let mockLastIndex + const { default: mocks } = require('./index.js') + for (const mock of mocks) { + app[mock.type](mock.url, mock.response) + mockLastIndex = app._router.stack.length + } + const mockRoutesLength = Object.keys(mocks).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)] + } + }) +} + +module.exports = app => { + // es6 polyfill + require('@babel/register') + + // parse app.body + // https://expressjs.com/en/4x/api.html#req.body + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ + extended: true + })) + + 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)) + } + } + }) +} diff --git a/hb_client/mock/table.js b/hb_client/mock/table.js new file mode 100644 index 0000000..ba95f76 --- /dev/null +++ b/hb_client/mock/table.js @@ -0,0 +1,29 @@ +import Mock from '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)' + }] +}) + +export default [ + { + url: '/vue-admin-template/table/list', + type: 'get', + response: config => { + const items = data.items + return { + code: 20000, + data: { + total: items.length, + items: items + } + } + } + } +] diff --git a/hb_client/mock/user.js b/hb_client/mock/user.js new file mode 100644 index 0000000..f007cd9 --- /dev/null +++ b/hb_client/mock/user.js @@ -0,0 +1,84 @@ + +const tokens = { + admin: { + token: 'admin-token' + }, + editor: { + token: 'editor-token' + } +} + +const users = { + 'admin-token': { + roles: ['admin'], + introduction: 'I am a super administrator', + avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', + name: 'Super Admin' + }, + 'editor-token': { + roles: ['editor'], + introduction: 'I am an editor', + avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', + name: 'Normal Editor' + } +} + +export default [ + // user login + { + url: '/vue-admin-template/user/login', + type: 'post', + response: config => { + const { username } = config.body + const token = tokens[username] + + // mock error + if (!token) { + return { + code: 60204, + message: 'Account and password are incorrect.' + } + } + + return { + code: 20000, + data: token + } + } + }, + + // get user info + { + url: '/vue-admin-template/user/info\.*', + type: 'get', + response: config => { + const { token } = config.query + const info = users[token] + + // mock error + if (!info) { + return { + code: 50008, + message: 'Login failed, unable to get user details.' + } + } + + return { + code: 20000, + data: info + } + } + }, + + // user logout + { + url: '/vue-admin-template/user/logout', + type: 'post', + response: _ => { + return { + code: 20000, + data: 'success' + } + } + } +] diff --git a/hb_client/package.json b/hb_client/package.json new file mode 100644 index 0000000..6e839a8 --- /dev/null +++ b/hb_client/package.json @@ -0,0 +1,68 @@ +{ + "name": "vue-admin-template", + "version": "4.2.1", + "description": "A vue admin template with Element UI & axios & iconfont & permission control & lint", + "author": "Pan ", + "license": "MIT", + "scripts": { + "dev": "vue-cli-service serve", + "dev:docker": "NODE_ENV=development PORT=80 ./node_modules/.bin/vue-cli-service serve", + "build:prod": "vue-cli-service build", + "build:stage": "vue-cli-service build --mode staging", + "preview": "node build/index.js --preview", + "lint": "eslint --ext .js,.vue src", + "test:unit": "jest --clearCache && vue-cli-service test:unit", + "test:ci": "npm run lint && npm run test:unit", + "svgo": "svgo -f src/icons/svg --config=src/icons/svgo.yml" + }, + "dependencies": { + "@riophae/vue-treeselect": "^0.4.0", + "axios": "0.18.1", + "element-ui": "2.13.0", + "file-saver": "^2.0.2", + "js-cookie": "2.2.0", + "normalize.css": "7.0.0", + "nprogress": "0.2.0", + "path-to-regexp": "2.4.0", + "vue": "2.6.10", + "vue-router": "3.0.6", + "vuex": "3.1.0", + "xlsx": "^0.15.5" + }, + "devDependencies": { + "@babel/core": "7.0.0", + "@babel/register": "7.0.0", + "@vue/cli-plugin-babel": "3.6.0", + "@vue/cli-plugin-eslint": "^3.9.1", + "@vue/cli-plugin-unit-jest": "3.6.3", + "@vue/cli-service": "3.6.0", + "@vue/test-utils": "1.0.0-beta.29", + "autoprefixer": "^9.5.1", + "babel-core": "7.0.0-bridge.0", + "babel-eslint": "10.0.1", + "babel-jest": "23.6.0", + "chalk": "2.4.2", + "connect": "3.6.6", + "eslint": "5.15.3", + "eslint-plugin-vue": "5.2.2", + "html-webpack-plugin": "3.2.0", + "mockjs": "1.0.1-beta3", + "node-sass": "^4.13.1", + "runjs": "^4.3.2", + "sass-loader": "^7.1.0", + "script-ext-html-webpack-plugin": "2.1.3", + "script-loader": "0.7.2", + "serve-static": "^1.13.2", + "svg-sprite-loader": "4.1.3", + "svgo": "1.2.2", + "vue-template-compiler": "2.6.10" + }, + "engines": { + "node": ">=8.9", + "npm": ">= 3.0.0" + }, + "browserslist": [ + "> 1%", + "last 2 versions" + ] +} diff --git a/hb_client/postcss.config.js b/hb_client/postcss.config.js new file mode 100644 index 0000000..10473ef --- /dev/null +++ b/hb_client/postcss.config.js @@ -0,0 +1,8 @@ +// https://github.com/michael-ciniawsky/postcss-load-config + +module.exports = { + 'plugins': { + // to edit target browsers: use "browserslist" field in package.json + 'autoprefixer': {} + } +} diff --git a/hb_client/public/favicon.ico b/hb_client/public/favicon.ico new file mode 100644 index 0000000..34b63ac Binary files /dev/null and b/hb_client/public/favicon.ico differ diff --git a/hb_client/public/index.html b/hb_client/public/index.html new file mode 100644 index 0000000..fa2be91 --- /dev/null +++ b/hb_client/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + <%= webpackConfig.name %> + + + +
+ + + diff --git a/hb_client/src/App.vue b/hb_client/src/App.vue new file mode 100644 index 0000000..ec9032c --- /dev/null +++ b/hb_client/src/App.vue @@ -0,0 +1,11 @@ + + + diff --git a/hb_client/src/api/dict.js b/hb_client/src/api/dict.js new file mode 100644 index 0000000..d46429f --- /dev/null +++ b/hb_client/src/api/dict.js @@ -0,0 +1,57 @@ +import request from '@/utils/request' + +export function getDictTypeList(query) { + return request({ + url: '/system/dicttype/', + method: 'get', + params: query + }) +} +export function createDictType(data) { + return request({ + url: '/system/dicttype/', + method: 'post', + data + }) +} +export function updateDictType(id, data) { + return request({ + url: `/system/dicttype/${id}/`, + method: 'put', + data + }) +} +export function deleteDictType(id) { + return request({ + url: `/system/dicttype/${id}/`, + method: 'delete' + }) +} + +export function getDictList(query) { + return request({ + url: '/system/dict/', + method: 'get', + params: query + }) +} +export function createDict(data) { + return request({ + url: '/system/dict/', + method: 'post', + data + }) +} +export function updateDict(id, data) { + return request({ + url: `/system/dict/${id}/`, + method: 'put', + data + }) +} +export function deleteDict(id) { + return request({ + url: `/system/dict/${id}/`, + method: 'delete' + }) +} diff --git a/hb_client/src/api/file.js b/hb_client/src/api/file.js new file mode 100644 index 0000000..01c683e --- /dev/null +++ b/hb_client/src/api/file.js @@ -0,0 +1,18 @@ +import { getToken } from "@/utils/auth" +import request from '@/utils/request' + +export function upUrl() { + return process.env.VUE_APP_BASE_API + '/file/' +} + +export function upHeaders() { + return { Authorization: "Bearer " + getToken() } +} + +export function getFileList(query) { + return request({ + url: '/file/', + method: 'get', + params: query + }) +} \ No newline at end of file diff --git a/hb_client/src/api/org.js b/hb_client/src/api/org.js new file mode 100644 index 0000000..2880ec4 --- /dev/null +++ b/hb_client/src/api/org.js @@ -0,0 +1,35 @@ +import request from '@/utils/request' + +export function getOrgAll() { + return request({ + url: '/system/organization/', + method: 'get' + }) +} +export function getOrgList(query) { + return request({ + url: '/system/organization/', + method: 'get', + params: query + }) +} +export function createOrg(data) { + return request({ + url: '/system/organization/', + method: 'post', + data + }) +} +export function updateOrg(id, data) { + return request({ + url: `/system/organization/${id}/`, + method: 'put', + data + }) +} +export function deleteOrg(id) { + return request({ + url: `/system/organization/${id}/`, + method: 'delete' + }) +} diff --git a/hb_client/src/api/perm.js b/hb_client/src/api/perm.js new file mode 100644 index 0000000..29ebf53 --- /dev/null +++ b/hb_client/src/api/perm.js @@ -0,0 +1,28 @@ +import request from '@/utils/request' + +export function getPermAll() { + return request({ + url: '/system/permission/', + method: 'get' + }) +} +export function createPerm(data) { + return request({ + url: '/system/permission/', + method: 'post', + data + }) +} +export function updatePerm(id, data) { + return request({ + url: `/system/permission/${id}/`, + method: 'put', + data + }) +} +export function deletePerm(id) { + return request({ + url: `/system/permission/${id}/`, + method: 'delete' + }) +} \ No newline at end of file diff --git a/hb_client/src/api/position.js b/hb_client/src/api/position.js new file mode 100644 index 0000000..2ea116f --- /dev/null +++ b/hb_client/src/api/position.js @@ -0,0 +1,31 @@ +import request from '@/utils/request' + +export function getPositionAll() { + return request({ + url: '/system/position/', + method: 'get' + }) +} + +export function createPosition(data) { + return request({ + url: '/system/position/', + method: 'post', + data + }) +} + +export function updatePosition(id, data) { + return request({ + url: `/system/position/${id}/`, + method: 'put', + data + }) +} + +export function deletePosition(id) { + return request({ + url: `/system/position/${id}/`, + method: 'delete' + }) +} diff --git a/hb_client/src/api/role.js b/hb_client/src/api/role.js new file mode 100644 index 0000000..e25e097 --- /dev/null +++ b/hb_client/src/api/role.js @@ -0,0 +1,38 @@ +import request from '@/utils/request' + +export function getRoutes() { + return request({ + url: '/system/permission/', + method: 'get' + }) +} + +export function getRoleAll() { + return request({ + url: '/system/role/', + method: 'get' + }) +} + +export function createRole(data) { + return request({ + url: '/system/role/', + method: 'post', + data + }) +} + +export function updateRole(id, data) { + return request({ + url: `/system/role/${id}/`, + method: 'put', + data + }) +} + +export function deleteRole(id) { + return request({ + url: `/system/role/${id}/`, + method: 'delete' + }) +} diff --git a/hb_client/src/api/table.js b/hb_client/src/api/table.js new file mode 100644 index 0000000..2752f52 --- /dev/null +++ b/hb_client/src/api/table.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +export function getList(params) { + return request({ + url: '/vue-admin-template/table/list', + method: 'get', + params + }) +} diff --git a/hb_client/src/api/task.js b/hb_client/src/api/task.js new file mode 100644 index 0000000..1e3b397 --- /dev/null +++ b/hb_client/src/api/task.js @@ -0,0 +1,45 @@ +import request from '@/utils/request' + +export function getptaskList(query) { + return request({ + url: '/system/ptask/', + method: 'get', + params: query + }) +} + +export function getTaskAll() { + return request({ + url: '/system/task/', + method: 'get' + }) +} +export function createptask(data) { + return request({ + url: '/system/ptask/', + method: 'post', + data + }) +} + +export function updateptask(id, data) { + return request({ + url: `/system/ptask/${id}/`, + method: 'put', + data + }) +} + +export function toggletask(id) { + return request({ + url: `/system/ptask/${id}/toggle/`, + method: 'put' + }) +} + +export function deleteptask(id) { + return request({ + url: `/system/ptask/${id}/`, + method: 'delete' + }) +} \ No newline at end of file diff --git a/hb_client/src/api/user.js b/hb_client/src/api/user.js new file mode 100644 index 0000000..8edf3ab --- /dev/null +++ b/hb_client/src/api/user.js @@ -0,0 +1,70 @@ +import request from '@/utils/request' + +export function login(data) { + return request({ + url: '/token/', + method: 'post', + data + }) +} + +export function logout() { + return request({ + url: '/token/black/', + method: 'get' + }) +} + +export function getInfo() { + return request({ + url: '/system/user/info/', + method: 'get' + }) +} + +export function getUserList(query) { + return request({ + url: '/system/user/', + method: 'get', + params: query + }) +} + +export function getUser(id) { + return request({ + url: `/system/user/${id}/`, + method: 'get' + }) +} + +export function createUser(data) { + return request({ + url: '/system/user/', + method: 'post', + data + }) +} + +export function updateUser(id, data) { + return request({ + url: `/system/user/${id}/`, + method: 'put', + data + }) +} + +export function deleteUser(id, data) { + return request({ + url: `/system/user/${id}/`, + method: 'delete', + data + }) +} + +export function changePassword(data) { + return request({ + url: '/system/user/password/', + method: 'put', + data + }) +} diff --git a/hb_client/src/assets/404_images/404.png b/hb_client/src/assets/404_images/404.png new file mode 100644 index 0000000..3d8e230 Binary files /dev/null and b/hb_client/src/assets/404_images/404.png differ diff --git a/hb_client/src/assets/404_images/404_cloud.png b/hb_client/src/assets/404_images/404_cloud.png new file mode 100644 index 0000000..c6281d0 Binary files /dev/null and b/hb_client/src/assets/404_images/404_cloud.png differ diff --git a/hb_client/src/components/Breadcrumb/index.vue b/hb_client/src/components/Breadcrumb/index.vue new file mode 100644 index 0000000..e65a60d --- /dev/null +++ b/hb_client/src/components/Breadcrumb/index.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/hb_client/src/components/Hamburger/index.vue b/hb_client/src/components/Hamburger/index.vue new file mode 100644 index 0000000..368b002 --- /dev/null +++ b/hb_client/src/components/Hamburger/index.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/hb_client/src/components/Pagination/index.vue b/hb_client/src/components/Pagination/index.vue new file mode 100644 index 0000000..e316e20 --- /dev/null +++ b/hb_client/src/components/Pagination/index.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/hb_client/src/components/SvgIcon/index.vue b/hb_client/src/components/SvgIcon/index.vue new file mode 100644 index 0000000..9a3318e --- /dev/null +++ b/hb_client/src/components/SvgIcon/index.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/hb_client/src/directive/el-table/adaptive.js b/hb_client/src/directive/el-table/adaptive.js new file mode 100644 index 0000000..298daea --- /dev/null +++ b/hb_client/src/directive/el-table/adaptive.js @@ -0,0 +1,42 @@ +import { addResizeListener, removeResizeListener } from 'element-ui/src/utils/resize-event' + +/** + * How to use + * ... + * el-table height is must be set + * bottomOffset: 30(default) // The height of the table from the bottom of the page. + */ + +const doResize = (el, binding, vnode) => { + const { componentInstance: $table } = vnode + + const { value } = binding + + if (!$table.height) { + throw new Error(`el-$table must set the height. Such as height='100px'`) + } + const bottomOffset = (value && value.bottomOffset) || 30 + + if (!$table) return + + const height = window.innerHeight - el.getBoundingClientRect().top - bottomOffset + $table.$nextTick(() => { + $table.layout.setHeight(height) + }) +} + +export default { + bind(el, binding, vnode) { + el.resizeListener = () => { + doResize(el, binding, vnode) + } + // parameter 1 is must be "Element" type + addResizeListener(window.document.body, el.resizeListener) + }, + inserted(el, binding, vnode) { + doResize(el, binding, vnode) + }, + unbind(el) { + removeResizeListener(window.document.body, el.resizeListener) + } +} diff --git a/hb_client/src/directive/el-table/index.js b/hb_client/src/directive/el-table/index.js new file mode 100644 index 0000000..d3d4515 --- /dev/null +++ b/hb_client/src/directive/el-table/index.js @@ -0,0 +1,13 @@ +import adaptive from './adaptive' + +const install = function(Vue) { + Vue.directive('el-height-adaptive-table', adaptive) +} + +if (window.Vue) { + window['el-height-adaptive-table'] = adaptive + Vue.use(install); // eslint-disable-line +} + +adaptive.install = install +export default adaptive diff --git a/hb_client/src/icons/index.js b/hb_client/src/icons/index.js new file mode 100644 index 0000000..2c6b309 --- /dev/null +++ b/hb_client/src/icons/index.js @@ -0,0 +1,9 @@ +import Vue from 'vue' +import SvgIcon from '@/components/SvgIcon'// svg component + +// register globally +Vue.component('svg-icon', SvgIcon) + +const req = require.context('./svg', false, /\.svg$/) +const requireAll = requireContext => requireContext.keys().map(requireContext) +requireAll(req) diff --git a/hb_client/src/icons/svg/404.svg b/hb_client/src/icons/svg/404.svg new file mode 100644 index 0000000..6df5019 --- /dev/null +++ b/hb_client/src/icons/svg/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/bug.svg b/hb_client/src/icons/svg/bug.svg new file mode 100644 index 0000000..05a150d --- /dev/null +++ b/hb_client/src/icons/svg/bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/chart.svg b/hb_client/src/icons/svg/chart.svg new file mode 100644 index 0000000..27728fb --- /dev/null +++ b/hb_client/src/icons/svg/chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/clipboard.svg b/hb_client/src/icons/svg/clipboard.svg new file mode 100644 index 0000000..90923ff --- /dev/null +++ b/hb_client/src/icons/svg/clipboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/component.svg b/hb_client/src/icons/svg/component.svg new file mode 100644 index 0000000..207ada3 --- /dev/null +++ b/hb_client/src/icons/svg/component.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/dashboard.svg b/hb_client/src/icons/svg/dashboard.svg new file mode 100644 index 0000000..5317d37 --- /dev/null +++ b/hb_client/src/icons/svg/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/documentation.svg b/hb_client/src/icons/svg/documentation.svg new file mode 100644 index 0000000..7043122 --- /dev/null +++ b/hb_client/src/icons/svg/documentation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/drag.svg b/hb_client/src/icons/svg/drag.svg new file mode 100644 index 0000000..4185d3c --- /dev/null +++ b/hb_client/src/icons/svg/drag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/edit.svg b/hb_client/src/icons/svg/edit.svg new file mode 100644 index 0000000..d26101f --- /dev/null +++ b/hb_client/src/icons/svg/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/education.svg b/hb_client/src/icons/svg/education.svg new file mode 100644 index 0000000..7bfb01d --- /dev/null +++ b/hb_client/src/icons/svg/education.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/email.svg b/hb_client/src/icons/svg/email.svg new file mode 100644 index 0000000..74d25e2 --- /dev/null +++ b/hb_client/src/icons/svg/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/example.svg b/hb_client/src/icons/svg/example.svg new file mode 100644 index 0000000..46f42b5 --- /dev/null +++ b/hb_client/src/icons/svg/example.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/excel.svg b/hb_client/src/icons/svg/excel.svg new file mode 100644 index 0000000..74d97b8 --- /dev/null +++ b/hb_client/src/icons/svg/excel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/exit-fullscreen.svg b/hb_client/src/icons/svg/exit-fullscreen.svg new file mode 100644 index 0000000..485c128 --- /dev/null +++ b/hb_client/src/icons/svg/exit-fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/eye-open.svg b/hb_client/src/icons/svg/eye-open.svg new file mode 100644 index 0000000..88dcc98 --- /dev/null +++ b/hb_client/src/icons/svg/eye-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/eye.svg b/hb_client/src/icons/svg/eye.svg new file mode 100644 index 0000000..16ed2d8 --- /dev/null +++ b/hb_client/src/icons/svg/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/form.svg b/hb_client/src/icons/svg/form.svg new file mode 100644 index 0000000..dcbaa18 --- /dev/null +++ b/hb_client/src/icons/svg/form.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/fullscreen.svg b/hb_client/src/icons/svg/fullscreen.svg new file mode 100644 index 0000000..0e86b6f --- /dev/null +++ b/hb_client/src/icons/svg/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/guide.svg b/hb_client/src/icons/svg/guide.svg new file mode 100644 index 0000000..b271001 --- /dev/null +++ b/hb_client/src/icons/svg/guide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/icon.svg b/hb_client/src/icons/svg/icon.svg new file mode 100644 index 0000000..82be8ee --- /dev/null +++ b/hb_client/src/icons/svg/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/international.svg b/hb_client/src/icons/svg/international.svg new file mode 100644 index 0000000..e9b56ee --- /dev/null +++ b/hb_client/src/icons/svg/international.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/language.svg b/hb_client/src/icons/svg/language.svg new file mode 100644 index 0000000..0082b57 --- /dev/null +++ b/hb_client/src/icons/svg/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/link.svg b/hb_client/src/icons/svg/link.svg new file mode 100644 index 0000000..48197ba --- /dev/null +++ b/hb_client/src/icons/svg/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/list.svg b/hb_client/src/icons/svg/list.svg new file mode 100644 index 0000000..20259ed --- /dev/null +++ b/hb_client/src/icons/svg/list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/lock.svg b/hb_client/src/icons/svg/lock.svg new file mode 100644 index 0000000..74fee54 --- /dev/null +++ b/hb_client/src/icons/svg/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/message.svg b/hb_client/src/icons/svg/message.svg new file mode 100644 index 0000000..14ca817 --- /dev/null +++ b/hb_client/src/icons/svg/message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/money.svg b/hb_client/src/icons/svg/money.svg new file mode 100644 index 0000000..c1580de --- /dev/null +++ b/hb_client/src/icons/svg/money.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/nested.svg b/hb_client/src/icons/svg/nested.svg new file mode 100644 index 0000000..06713a8 --- /dev/null +++ b/hb_client/src/icons/svg/nested.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/password.svg b/hb_client/src/icons/svg/password.svg new file mode 100644 index 0000000..e291d85 --- /dev/null +++ b/hb_client/src/icons/svg/password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/pdf.svg b/hb_client/src/icons/svg/pdf.svg new file mode 100644 index 0000000..957aa0c --- /dev/null +++ b/hb_client/src/icons/svg/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/people.svg b/hb_client/src/icons/svg/people.svg new file mode 100644 index 0000000..2bd54ae --- /dev/null +++ b/hb_client/src/icons/svg/people.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/peoples.svg b/hb_client/src/icons/svg/peoples.svg new file mode 100644 index 0000000..aab852e --- /dev/null +++ b/hb_client/src/icons/svg/peoples.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/position.svg b/hb_client/src/icons/svg/position.svg new file mode 100644 index 0000000..f89f0e0 --- /dev/null +++ b/hb_client/src/icons/svg/position.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/qq.svg b/hb_client/src/icons/svg/qq.svg new file mode 100644 index 0000000..ee13d4e --- /dev/null +++ b/hb_client/src/icons/svg/qq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/search.svg b/hb_client/src/icons/svg/search.svg new file mode 100644 index 0000000..84233dd --- /dev/null +++ b/hb_client/src/icons/svg/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/shopping.svg b/hb_client/src/icons/svg/shopping.svg new file mode 100644 index 0000000..87513e7 --- /dev/null +++ b/hb_client/src/icons/svg/shopping.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/size.svg b/hb_client/src/icons/svg/size.svg new file mode 100644 index 0000000..ddb25b8 --- /dev/null +++ b/hb_client/src/icons/svg/size.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/skill.svg b/hb_client/src/icons/svg/skill.svg new file mode 100644 index 0000000..a3b7312 --- /dev/null +++ b/hb_client/src/icons/svg/skill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/star.svg b/hb_client/src/icons/svg/star.svg new file mode 100644 index 0000000..6cf86e6 --- /dev/null +++ b/hb_client/src/icons/svg/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/tab.svg b/hb_client/src/icons/svg/tab.svg new file mode 100644 index 0000000..b4b48e4 --- /dev/null +++ b/hb_client/src/icons/svg/tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/table.svg b/hb_client/src/icons/svg/table.svg new file mode 100644 index 0000000..0e3dc9d --- /dev/null +++ b/hb_client/src/icons/svg/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/theme.svg b/hb_client/src/icons/svg/theme.svg new file mode 100644 index 0000000..5982a2f --- /dev/null +++ b/hb_client/src/icons/svg/theme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/tree-table.svg b/hb_client/src/icons/svg/tree-table.svg new file mode 100644 index 0000000..8aafdb8 --- /dev/null +++ b/hb_client/src/icons/svg/tree-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/tree.svg b/hb_client/src/icons/svg/tree.svg new file mode 100644 index 0000000..dd4b7dd --- /dev/null +++ b/hb_client/src/icons/svg/tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/user.svg b/hb_client/src/icons/svg/user.svg new file mode 100644 index 0000000..0ba0716 --- /dev/null +++ b/hb_client/src/icons/svg/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/wechat.svg b/hb_client/src/icons/svg/wechat.svg new file mode 100644 index 0000000..c586e55 --- /dev/null +++ b/hb_client/src/icons/svg/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svg/zip.svg b/hb_client/src/icons/svg/zip.svg new file mode 100644 index 0000000..f806fc4 --- /dev/null +++ b/hb_client/src/icons/svg/zip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hb_client/src/icons/svgo.yml b/hb_client/src/icons/svgo.yml new file mode 100644 index 0000000..d11906a --- /dev/null +++ b/hb_client/src/icons/svgo.yml @@ -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' diff --git a/hb_client/src/layout/components/AppMain.vue b/hb_client/src/layout/components/AppMain.vue new file mode 100644 index 0000000..f6a3286 --- /dev/null +++ b/hb_client/src/layout/components/AppMain.vue @@ -0,0 +1,40 @@ + + + + + + + diff --git a/hb_client/src/layout/components/Navbar.vue b/hb_client/src/layout/components/Navbar.vue new file mode 100644 index 0000000..d053e87 --- /dev/null +++ b/hb_client/src/layout/components/Navbar.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/hb_client/src/layout/components/Sidebar/FixiOSBug.js b/hb_client/src/layout/components/Sidebar/FixiOSBug.js new file mode 100644 index 0000000..bc14856 --- /dev/null +++ b/hb_client/src/layout/components/Sidebar/FixiOSBug.js @@ -0,0 +1,26 @@ +export default { + computed: { + device() { + return this.$store.state.app.device + } + }, + mounted() { + // In order to fix the click on menu on the ios device will trigger the mouseleave bug + // https://github.com/PanJiaChen/vue-element-admin/issues/1135 + this.fixBugIniOS() + }, + methods: { + fixBugIniOS() { + const $subMenu = this.$refs.subMenu + if ($subMenu) { + const handleMouseleave = $subMenu.handleMouseleave + $subMenu.handleMouseleave = (e) => { + if (this.device === 'mobile') { + return + } + handleMouseleave(e) + } + } + } + } +} diff --git a/hb_client/src/layout/components/Sidebar/Item.vue b/hb_client/src/layout/components/Sidebar/Item.vue new file mode 100644 index 0000000..b515f61 --- /dev/null +++ b/hb_client/src/layout/components/Sidebar/Item.vue @@ -0,0 +1,29 @@ + diff --git a/hb_client/src/layout/components/Sidebar/Link.vue b/hb_client/src/layout/components/Sidebar/Link.vue new file mode 100644 index 0000000..eb4dd10 --- /dev/null +++ b/hb_client/src/layout/components/Sidebar/Link.vue @@ -0,0 +1,36 @@ + + + + diff --git a/hb_client/src/layout/components/Sidebar/Logo.vue b/hb_client/src/layout/components/Sidebar/Logo.vue new file mode 100644 index 0000000..7121590 --- /dev/null +++ b/hb_client/src/layout/components/Sidebar/Logo.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/hb_client/src/layout/components/Sidebar/SidebarItem.vue b/hb_client/src/layout/components/Sidebar/SidebarItem.vue new file mode 100644 index 0000000..a418c3d --- /dev/null +++ b/hb_client/src/layout/components/Sidebar/SidebarItem.vue @@ -0,0 +1,95 @@ + + + diff --git a/hb_client/src/layout/components/Sidebar/index.vue b/hb_client/src/layout/components/Sidebar/index.vue new file mode 100644 index 0000000..fb014a2 --- /dev/null +++ b/hb_client/src/layout/components/Sidebar/index.vue @@ -0,0 +1,54 @@ + + + diff --git a/hb_client/src/layout/components/index.js b/hb_client/src/layout/components/index.js new file mode 100644 index 0000000..97ee3cd --- /dev/null +++ b/hb_client/src/layout/components/index.js @@ -0,0 +1,3 @@ +export { default as Navbar } from './Navbar' +export { default as Sidebar } from './Sidebar' +export { default as AppMain } from './AppMain' diff --git a/hb_client/src/layout/index.vue b/hb_client/src/layout/index.vue new file mode 100644 index 0000000..db22a7b --- /dev/null +++ b/hb_client/src/layout/index.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/hb_client/src/layout/mixin/ResizeHandler.js b/hb_client/src/layout/mixin/ResizeHandler.js new file mode 100644 index 0000000..e8d0df8 --- /dev/null +++ b/hb_client/src/layout/mixin/ResizeHandler.js @@ -0,0 +1,45 @@ +import store from '@/store' + +const { body } = document +const WIDTH = 992 // refer to Bootstrap's responsive design + +export default { + watch: { + $route(route) { + if (this.device === 'mobile' && this.sidebar.opened) { + store.dispatch('app/closeSideBar', { withoutAnimation: false }) + } + } + }, + beforeMount() { + window.addEventListener('resize', this.$_resizeHandler) + }, + beforeDestroy() { + window.removeEventListener('resize', this.$_resizeHandler) + }, + mounted() { + const isMobile = this.$_isMobile() + if (isMobile) { + store.dispatch('app/toggleDevice', 'mobile') + store.dispatch('app/closeSideBar', { withoutAnimation: true }) + } + }, + methods: { + // use $_ for mixins properties + // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential + $_isMobile() { + const rect = body.getBoundingClientRect() + return rect.width - 1 < WIDTH + }, + $_resizeHandler() { + if (!document.hidden) { + const isMobile = this.$_isMobile() + store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop') + + if (isMobile) { + store.dispatch('app/closeSideBar', { withoutAnimation: true }) + } + } + } + } +} diff --git a/hb_client/src/main.js b/hb_client/src/main.js new file mode 100644 index 0000000..701c67a --- /dev/null +++ b/hb_client/src/main.js @@ -0,0 +1,44 @@ +import Vue from 'vue' + +import 'normalize.css/normalize.css' // A modern alternative to CSS resets + +import ElementUI from 'element-ui' +import 'element-ui/lib/theme-chalk/index.css' +// import locale from 'element-ui/lib/locale/lang/en' // lang i18n + +import '@/styles/index.scss' // global css + +import App from './App' +import store from './store' +import router from './router' + +import '@/icons' // icon +import '@/permission' // permission control +import tableHeight from '@/directive/el-table/index' +Vue.use(tableHeight) +/** + * 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() +} + +// set ElementUI lang to EN +// Vue.use(ElementUI, { locale }) +// 如果想要中文版 element-ui,按如下方式声明 +Vue.use(ElementUI, { size: 'medium' }) +Vue.config.productionTip = false + + +new Vue({ + el: '#app', + router, + store, + render: h => h(App) +}) diff --git a/hb_client/src/permission.js b/hb_client/src/permission.js new file mode 100644 index 0000000..d4a08b6 --- /dev/null +++ b/hb_client/src/permission.js @@ -0,0 +1,73 @@ +import router from './router' +import store from './store' +import { Message } from 'element-ui' +import NProgress from 'nprogress' // progress bar +import 'nprogress/nprogress.css' // progress bar style +import { getToken } from '@/utils/auth' // get token from cookie +import getPageTitle from '@/utils/get-page-title' + +NProgress.configure({ showSpinner: false }) // NProgress Configuration + +const whiteList = ['/login'] // no redirect whitelist + +router.beforeEach(async(to, from, next) => { + // start progress bar + NProgress.start() + + // set page title + document.title = getPageTitle(to.meta.title) + + // determine whether the user has logged in + const hasToken = getToken() + + if (hasToken) { + if (to.path === '/login') { + // if is logged in, redirect to the home page + next({ path: '/' }) + NProgress.done() + } else { + // determine whether the user has obtained his permission perms through getInfo + const hasPerms = store.getters.perms && store.getters.perms.length > 0 + if (hasPerms) { + next() + } else { + try { + // get user info + // note: perms must be a object array! such as: ['admin'] or ,['developer','editor'] + const { perms } = await store.dispatch('user/getInfo') + // generate accessible routes map based on perms + const accessRoutes = await store.dispatch('permission/generateRoutes', perms) + + // dynamically add accessible routes + router.addRoutes(accessRoutes) + + // hack method to ensure that addRoutes is complete + // set the replace: true, so the navigation will not leave a history record + next({ ...to, replace: true }) + } catch (error) { + // remove token and go to login page to re-login + await store.dispatch('user/resetToken') + Message.error(error || 'Has Error') + next(`/login?redirect=${to.path}`) + NProgress.done() + } + } + } + } else { + /* has no token*/ + + if (whiteList.indexOf(to.path) !== -1) { + // in the free login whitelist, go directly + next() + } else { + // other pages that do not have permission to access are redirected to the login page. + next(`/login?redirect=${to.path}`) + NProgress.done() + } + } +}) + +router.afterEach(() => { + // finish progress bar + NProgress.done() +}) diff --git a/hb_client/src/router/index.js b/hb_client/src/router/index.js new file mode 100644 index 0000000..8d33a62 --- /dev/null +++ b/hb_client/src/router/index.js @@ -0,0 +1,195 @@ +import Vue from 'vue' +import Router from 'vue-router' + +Vue.use(Router) + +/* Layout */ +import Layout from '@/layout' + +/** + * Note: sub-menu only appear when route children.length >= 1 + * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html + * + * hidden: true if set true, item will not show in the sidebar(default is false) + * alwaysShow: true if set true, will always show the root menu + * if not set alwaysShow, when item has more than one children route, + * it will becomes nested mode, otherwise not show the root menu + * redirect: noRedirect if set noRedirect will no redirect in the breadcrumb + * name:'router-name' the name is used by (must set!!!) + * meta : { + perms: ['admin','editor'] control the page perms (you can set multiple perms) + title: 'title' the name show in sidebar and breadcrumb (recommend set) + icon: 'svg-name' the icon show in the sidebar + breadcrumb: false if set false, the item will hidden in breadcrumb(default is true) + activeMenu: '/example/list' if set path, the sidebar will highlight the path you set + } + */ + +/** + * constantRoutes + * a base page that does not have permission requirements + * all perms can be accessed + */ +export const constantRoutes = [ + { + path: '/login', + component: () => import('@/views/login/index'), + hidden: true + }, + + { + path: '/404', + component: () => import('@/views/404'), + hidden: true + }, + { + path: '/', + component: Layout, + redirect: '/dashboard', + children: [{ + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/dashboard/index'), + meta: { title: '首页', icon: 'dashboard' } + }] + }, + { + path: '/changepassword', + component: Layout, + redirect: '/changepassword', + name: 'ChangePW', + meta: { title: '修改密码', icon: 'tree' }, + hidden:true, + children: [ + { + path: '', + name: 'ChangePassword', + component: () => import('@/views/system/changepassword'), + meta: { title: '修改密码', noCache: true, icon: ''}, + hidden: true + }, + ] + }, + +] + +/** + * asyncRoutes + * the routes that need to be dynamically loaded based on user perms + */ +export const asyncRoutes = [ + { + path: '/system', + component: Layout, + redirect: '/system/user', + name: 'System', + meta: { title: '系统管理', icon: 'example', perms: ['system_manage'] }, + children: [ + { + path: 'user', + name: 'User', + component: () => import('@/views/system/user.vue'), + meta: { title: '用户管理', icon: 'user', perms: ['user_manage'] } + }, + { + path: 'organization', + name: 'Organization', + component: () => import('@/views/system/organization'), + meta: { title: '部门管理', icon: 'tree', perms: ['org_manage'] } + }, + { + path: 'role', + name: 'Role', + component: () => import('@/views/system/role'), + meta: { title: '角色管理', icon: 'lock', perms: ['role_manage'] } + }, + { + path: 'position', + name: 'Postion', + component: () => import('@/views/system/position'), + meta: { title: '岗位管理', icon: 'position', perms: ['position_manage'] } + }, + { + path: 'dict', + name: 'Dict', + component: () => import('@/views/system/dict'), + meta: { title: '数据字典', icon: 'example', perms: ['dict_manage'] } + }, + { + path: 'file', + name: 'File', + component: () => import('@/views/system/file'), + meta: { title: '文件库', icon: 'documentation', perms: ['file_room'] } + }, + { + path: 'task', + name: 'Task', + component: () => import('@/views/system/task'), + meta: { title: '定时任务', icon: 'list', perms: ['ptask_manage'] } + } + ] + }, + { + path: '/develop', + component: Layout, + redirect: '/develop/perm', + name: 'Develop', + meta: { title: '开发配置', icon: 'example', perms: ['dev_set'] }, + children: [ + { + path: 'perm', + name: 'Perm', + component: () => import('@/views/system/perm'), + meta: { title: '权限菜单', icon: 'example', perms: ['perm_manage'] } + }, + { + path: 'form-gen-link', + component: Layout, + children: [ + { + path: 'https://jakhuang.github.io/form-generator/', + meta: { title: '表单设计器', icon: 'link', perms: ['dev_form_gen'] } + } + ] + }, + { + path: 'docs-link', + component: Layout, + children: [ + { + path: process.env.VUE_APP_BASE_API + '/docs/', + meta: { title: '接口文档', icon: 'link', perms: ['dev_docs'] } + } + ] + }, + { + path: 'admin-link', + component: Layout, + children: [ + { + path: process.env.VUE_APP_BASE_API + '/admin/', + meta: { title: 'Django后台', icon: 'link', perms: ['dev_admin'] } + } + ] + } + ] + }, + // 404 page must be placed at the end !!! + { path: '*', redirect: '/404', hidden: true } +] + +const createRouter = () => new Router({ + // mode: 'history', // require service support + scrollBehavior: () => ({ y: 0 }), + routes: constantRoutes +}) + +const router = createRouter() + +// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465 +export function resetRouter() { + const newRouter = createRouter() + router.matcher = newRouter.matcher // reset router +} + +export default router diff --git a/hb_client/src/settings.js b/hb_client/src/settings.js new file mode 100644 index 0000000..585109b --- /dev/null +++ b/hb_client/src/settings.js @@ -0,0 +1,16 @@ +module.exports = { + + title: '管理系统', + + /** + * @type {boolean} true | false + * @description Whether fix the header + */ + fixedHeader: false, + + /** + * @type {boolean} true | false + * @description Whether show the logo in sidebar + */ + sidebarLogo: true +} diff --git a/hb_client/src/store/getters.js b/hb_client/src/store/getters.js new file mode 100644 index 0000000..1854b88 --- /dev/null +++ b/hb_client/src/store/getters.js @@ -0,0 +1,10 @@ +const getters = { + sidebar: state => state.app.sidebar, + device: state => state.app.device, + token: state => state.user.token, + avatar: state => state.user.avatar, + name: state => state.user.name, + perms: state => state.user.perms, + permission_routes: state => state.permission.routes +} +export default getters diff --git a/hb_client/src/store/index.js b/hb_client/src/store/index.js new file mode 100644 index 0000000..6ae5dad --- /dev/null +++ b/hb_client/src/store/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue' +import Vuex from 'vuex' +import getters from './getters' +import app from './modules/app' +import permission from './modules/permission' +import settings from './modules/settings' +import user from './modules/user' + +Vue.use(Vuex) + +const store = new Vuex.Store({ + modules: { + app, + permission, + settings, + user + }, + getters +}) + +export default store diff --git a/hb_client/src/store/modules/app.js b/hb_client/src/store/modules/app.js new file mode 100644 index 0000000..7ea7e33 --- /dev/null +++ b/hb_client/src/store/modules/app.js @@ -0,0 +1,48 @@ +import Cookies from 'js-cookie' + +const state = { + sidebar: { + opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true, + withoutAnimation: false + }, + device: 'desktop' +} + +const mutations = { + TOGGLE_SIDEBAR: state => { + state.sidebar.opened = !state.sidebar.opened + state.sidebar.withoutAnimation = false + if (state.sidebar.opened) { + Cookies.set('sidebarStatus', 1) + } else { + Cookies.set('sidebarStatus', 0) + } + }, + CLOSE_SIDEBAR: (state, withoutAnimation) => { + Cookies.set('sidebarStatus', 0) + state.sidebar.opened = false + state.sidebar.withoutAnimation = withoutAnimation + }, + TOGGLE_DEVICE: (state, device) => { + state.device = device + } +} + +const actions = { + toggleSideBar({ commit }) { + commit('TOGGLE_SIDEBAR') + }, + closeSideBar({ commit }, { withoutAnimation }) { + commit('CLOSE_SIDEBAR', withoutAnimation) + }, + toggleDevice({ commit }, device) { + commit('TOGGLE_DEVICE', device) + } +} + +export default { + namespaced: true, + state, + mutations, + actions +} diff --git a/hb_client/src/store/modules/permission.js b/hb_client/src/store/modules/permission.js new file mode 100644 index 0000000..1ae691e --- /dev/null +++ b/hb_client/src/store/modules/permission.js @@ -0,0 +1,68 @@ +import { asyncRoutes, constantRoutes } from '@/router' + +/** + * Use meta.perm to determine if the current user has permission + * @param perms + * @param route + */ +function hasPermission(perms, route) { + if (route.meta && route.meta.perms) { + return perms.some(perm => route.meta.perms.includes(perm)) + } else { + return true + } +} + +/** + * Filter asynchronous routing tables by recursion + * @param routes asyncRoutes + * @param perms + */ +export function filterAsyncRoutes(routes, perms) { + const res = [] + + routes.forEach(route => { + const tmp = { ...route } + if (hasPermission(perms, tmp)) { + if (tmp.children) { + tmp.children = filterAsyncRoutes(tmp.children, perms) + } + res.push(tmp) + } + }) + return res +} + +const state = { + routes: [], + addRoutes: [] +} + +const mutations = { + SET_ROUTES: (state, routes) => { + state.addRoutes = routes + state.routes = constantRoutes.concat(routes) + } +} + +const actions = { + generateRoutes({ commit }, perms) { + return new Promise(resolve => { + let accessedRoutes + if (perms.includes('admin')) { + accessedRoutes = asyncRoutes || [] + } else { + accessedRoutes = filterAsyncRoutes(asyncRoutes, perms) + } + commit('SET_ROUTES', accessedRoutes) + resolve(accessedRoutes) + }) + } +} + +export default { + namespaced: true, + state, + mutations, + actions +} diff --git a/hb_client/src/store/modules/settings.js b/hb_client/src/store/modules/settings.js new file mode 100644 index 0000000..aab31a2 --- /dev/null +++ b/hb_client/src/store/modules/settings.js @@ -0,0 +1,31 @@ +import defaultSettings from '@/settings' + +const { showSettings, fixedHeader, sidebarLogo } = defaultSettings + +const state = { + showSettings: showSettings, + fixedHeader: fixedHeader, + sidebarLogo: sidebarLogo +} + +const mutations = { + CHANGE_SETTING: (state, { key, value }) => { + if (state.hasOwnProperty(key)) { + state[key] = value + } + } +} + +const actions = { + changeSetting({ commit }, data) { + commit('CHANGE_SETTING', data) + } +} + +export default { + namespaced: true, + state, + mutations, + actions +} + diff --git a/hb_client/src/store/modules/user.js b/hb_client/src/store/modules/user.js new file mode 100644 index 0000000..3dba2c2 --- /dev/null +++ b/hb_client/src/store/modules/user.js @@ -0,0 +1,108 @@ +import { login, logout, getInfo } from '@/api/user' +import { getToken, setToken, removeToken } from '@/utils/auth' +import { resetRouter } from '@/router' + +const getDefaultState = () => { + return { + token: getToken(), + name: '', + avatar: '', + perms: [] + } +} + +const state = getDefaultState() + +const mutations = { + RESET_STATE: (state) => { + Object.assign(state, getDefaultState()) + }, + SET_TOKEN: (state, token) => { + state.token = token + }, + SET_NAME: (state, name) => { + state.name = name + }, + SET_AVATAR: (state, avatar) => { + state.avatar = avatar + }, + SET_PERMS: (state, perms) => { + state.perms = perms + } +} + +const actions = { + // user login + login({ commit }, userInfo) { + const { username, password } = userInfo + return new Promise((resolve, reject) => { + login({ username: username.trim(), password: password }).then(response => { + const { data } = response + commit('SET_TOKEN', data.access) + setToken(data.access) + resolve() + + }).catch(error => { + reject(error) + }) + }) + }, + + // get user info + getInfo({ commit, state }) { + return new Promise((resolve, reject) => { + getInfo(state.token).then(response => { + const { data } = response + + if (!data) { + reject('验证失败,重新登陆.') + } + + const { perms, name, avatar } = data + + // perms must be a non-empty array + if (!perms || perms.length <= 0) { + reject('没有任何权限!') + } + + commit('SET_PERMS', perms) + commit('SET_NAME', name) + commit('SET_AVATAR', avatar) + resolve(data) + }).catch(error => { + reject(error) + }) + }) + }, + + // user logout + logout({ commit, state }) { + return new Promise((resolve, reject) => { + logout(state.token).then(() => { + removeToken() // must remove token first + resetRouter() + commit('RESET_STATE') + resolve() + }).catch(error => { + reject(error) + }) + }) + }, + + // remove token + resetToken({ commit }) { + return new Promise(resolve => { + removeToken() // must remove token first + commit('RESET_STATE') + resolve() + }) + } +} + +export default { + namespaced: true, + state, + mutations, + actions +} + diff --git a/hb_client/src/styles/element-ui.scss b/hb_client/src/styles/element-ui.scss new file mode 100644 index 0000000..0062411 --- /dev/null +++ b/hb_client/src/styles/element-ui.scss @@ -0,0 +1,49 @@ +// cover some element-ui styles + +.el-breadcrumb__inner, +.el-breadcrumb__inner a { + font-weight: 400 !important; +} + +.el-upload { + input[type="file"] { + display: none !important; + } +} + +.el-upload__input { + display: none; +} + + +// to fixed https://github.com/ElemeFE/element/issues/2461 +.el-dialog { + transform: none; + left: 0; + position: relative; + margin: 0 auto; +} + +// refine element ui upload +.upload-container { + .el-upload { + width: 100%; + + .el-upload-dragger { + width: 100%; + height: 200px; + } + } +} + +// dropdown +.el-dropdown-menu { + a { + display: block + } +} + +// to fix el-date-picker css style +.el-range-separator { + box-sizing: content-box; +} diff --git a/hb_client/src/styles/index.scss b/hb_client/src/styles/index.scss new file mode 100644 index 0000000..059de63 --- /dev/null +++ b/hb_client/src/styles/index.scss @@ -0,0 +1,124 @@ +@import './variables.scss'; +@import './mixin.scss'; +@import './transition.scss'; +@import './element-ui.scss'; +@import './sidebar.scss'; + +body { + height: 100%; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; +} + +label { + font-weight: 700; +} + +html { + height: 100%; + box-sizing: border-box; +} + +#app { + height: 100%; +} + +*, +*:before, +*:after { + box-sizing: inherit; +} + +a:focus, +a:active { + outline: none; +} + +a, +a:focus, +a:hover { + cursor: pointer; + color: inherit; + text-decoration: none; +} + +div:focus { + outline: none; +} + +.clearfix { + &:after { + visibility: hidden; + display: block; + font-size: 0; + content: " "; + clear: both; + height: 0; + } +} + +// main-container global css +.app-container { + padding: 10px; +} + +.el-table--medium td,   .el-table--medium th { + padding: 2px 0; +} +.el-form-item { + margin-bottom: 16px; +} +.el-card, .el-message { + border-radius: 0px; + overflow: hidden; +} +.el-card__body { + padding: 6px; +} +.el-card__header { + padding: 6px; +} +.el-tabs--border-card>.el-tabs__content { + padding: 6px; +} +.el-dialog__header { + padding: 10px 10px 6px; +} +// .el-dialog{ +// display: flex; +// flex-direction: column; +// margin:0 !important; +// position:absolute; +// top:50%; +// left:50%; +// transform:translate(-50%,-50%); +// /*height:600px;*/ +// max-height:calc(100% - 30px); +// max-width:calc(100% - 30px); +// } +.el-dialog .el-dialog__body{ + // flex:1; + // overflow: auto; + padding: 8px 12px; +} + +.el-form--label-top .el-form-item__label { + line-height: 16px; +} +.el-button+.el-button { + margin-left: 1px; +} +.el-tabs__header { + margin: 0 0 6px; +} +.pagination-container { + padding: 0px 0px; +} +body .el-table th.gutter{ + display: table-cell!important; +} +.el-dialog__footer{ + padding: 6px 6px 6px; +} diff --git a/hb_client/src/styles/mixin.scss b/hb_client/src/styles/mixin.scss new file mode 100644 index 0000000..36b74bb --- /dev/null +++ b/hb_client/src/styles/mixin.scss @@ -0,0 +1,28 @@ +@mixin clearfix { + &:after { + content: ""; + display: table; + clear: both; + } +} + +@mixin scrollBar { + &::-webkit-scrollbar-track-piece { + background: #d3dce6; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #99a9bf; + border-radius: 20px; + } +} + +@mixin relative { + position: relative; + width: 100%; + height: 100%; +} diff --git a/hb_client/src/styles/sidebar.scss b/hb_client/src/styles/sidebar.scss new file mode 100644 index 0000000..3dad4c3 --- /dev/null +++ b/hb_client/src/styles/sidebar.scss @@ -0,0 +1,209 @@ +#app { + + .main-container { + min-height: 100%; + transition: margin-left .28s; + margin-left: $sideBarWidth; + position: relative; + } + + .sidebar-container { + transition: width 0.28s; + width: $sideBarWidth !important; + background-color: $menuBg; + height: 100%; + position: fixed; + font-size: 0px; + top: 0; + bottom: 0; + left: 0; + z-index: 1001; + overflow: hidden; + + // reset element-ui css + .horizontal-collapse-transition { + transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out; + } + + .scrollbar-wrapper { + overflow-x: hidden !important; + } + + .el-scrollbar__bar.is-vertical { + right: 0px; + } + + .el-scrollbar { + height: 100%; + } + + &.has-logo { + .el-scrollbar { + height: calc(100% - 50px); + } + } + + .is-horizontal { + display: none; + } + + a { + display: inline-block; + width: 100%; + overflow: hidden; + } + + .svg-icon { + margin-right: 16px; + } + + .el-menu { + border: none; + height: 100%; + width: 100% !important; + } + + // menu hover + .submenu-title-noDropdown, + .el-submenu__title { + &:hover { + background-color: $menuHover !important; + } + } + + .is-active>.el-submenu__title { + color: $subMenuActiveText !important; + } + + & .nest-menu .el-submenu>.el-submenu__title, + & .el-submenu .el-menu-item { + min-width: $sideBarWidth !important; + background-color: $subMenuBg !important; + + &:hover { + background-color: $subMenuHover !important; + } + } + } + + .hideSidebar { + .sidebar-container { + width: 54px !important; + } + + .main-container { + margin-left: 54px; + } + + .submenu-title-noDropdown { + padding: 0 !important; + position: relative; + + .el-tooltip { + padding: 0 !important; + + .svg-icon { + margin-left: 20px; + } + } + } + + .el-submenu { + overflow: hidden; + + &>.el-submenu__title { + padding: 0 !important; + + .svg-icon { + margin-left: 20px; + } + + .el-submenu__icon-arrow { + display: none; + } + } + } + + .el-menu--collapse { + .el-submenu { + &>.el-submenu__title { + &>span { + height: 0; + width: 0; + overflow: hidden; + visibility: hidden; + display: inline-block; + } + } + } + } + } + + .el-menu--collapse .el-menu .el-submenu { + min-width: $sideBarWidth !important; + } + + // mobile responsive + .mobile { + .main-container { + margin-left: 0px; + } + + .sidebar-container { + transition: transform .28s; + width: $sideBarWidth !important; + } + + &.hideSidebar { + .sidebar-container { + pointer-events: none; + transition-duration: 0.3s; + transform: translate3d(-$sideBarWidth, 0, 0); + } + } + } + + .withoutAnimation { + + .main-container, + .sidebar-container { + transition: none; + } + } +} + +// when menu collapsed +.el-menu--vertical { + &>.el-menu { + .svg-icon { + margin-right: 16px; + } + } + + .nest-menu .el-submenu>.el-submenu__title, + .el-menu-item { + &:hover { + // you can use $subMenuHover + background-color: $menuHover !important; + } + } + + // the scroll bar appears when the subMenu is too long + >.el-menu--popup { + max-height: 100vh; + overflow-y: auto; + + &::-webkit-scrollbar-track-piece { + background: #d3dce6; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: #99a9bf; + border-radius: 20px; + } + } +} diff --git a/hb_client/src/styles/transition.scss b/hb_client/src/styles/transition.scss new file mode 100644 index 0000000..4cb27cc --- /dev/null +++ b/hb_client/src/styles/transition.scss @@ -0,0 +1,48 @@ +// global transition css + +/* fade */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.28s; +} + +.fade-enter, +.fade-leave-active { + opacity: 0; +} + +/* fade-transform */ +.fade-transform-leave-active, +.fade-transform-enter-active { + transition: all .5s; +} + +.fade-transform-enter { + opacity: 0; + transform: translateX(-30px); +} + +.fade-transform-leave-to { + opacity: 0; + transform: translateX(30px); +} + +/* breadcrumb transition */ +.breadcrumb-enter-active, +.breadcrumb-leave-active { + transition: all .5s; +} + +.breadcrumb-enter, +.breadcrumb-leave-active { + opacity: 0; + transform: translateX(20px); +} + +.breadcrumb-move { + transition: all .5s; +} + +.breadcrumb-leave-active { + position: absolute; +} diff --git a/hb_client/src/styles/variables.scss b/hb_client/src/styles/variables.scss new file mode 100644 index 0000000..be55772 --- /dev/null +++ b/hb_client/src/styles/variables.scss @@ -0,0 +1,25 @@ +// sidebar +$menuText:#bfcbd9; +$menuActiveText:#409EFF; +$subMenuActiveText:#f4f4f5; //https://github.com/ElemeFE/element/issues/12951 + +$menuBg:#304156; +$menuHover:#263445; + +$subMenuBg:#1f2d3d; +$subMenuHover:#001528; + +$sideBarWidth: 210px; + +// the :export directive is the magic sauce for webpack +// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass +:export { + menuText: $menuText; + menuActiveText: $menuActiveText; + subMenuActiveText: $subMenuActiveText; + menuBg: $menuBg; + menuHover: $menuHover; + subMenuBg: $subMenuBg; + subMenuHover: $subMenuHover; + sideBarWidth: $sideBarWidth; +} diff --git a/hb_client/src/utils/auth.js b/hb_client/src/utils/auth.js new file mode 100644 index 0000000..392db62 --- /dev/null +++ b/hb_client/src/utils/auth.js @@ -0,0 +1,25 @@ +import Cookies from 'js-cookie' + +const TokenKey = 'token' + +export function getToken() { + return Cookies.get(TokenKey) +} + +export function setToken(token) { + return Cookies.set(TokenKey, token) +} + +export function removeToken() { + return Cookies.remove(TokenKey) +} + +// export function refreshToken() { +// let token = getToken() +// let data = {"token": token} +// return request({ +// url: '/token/refresh/', +// method: 'post', +// data +// }) +// } diff --git a/hb_client/src/utils/get-page-title.js b/hb_client/src/utils/get-page-title.js new file mode 100644 index 0000000..cfe5800 --- /dev/null +++ b/hb_client/src/utils/get-page-title.js @@ -0,0 +1,10 @@ +import defaultSettings from '@/settings' + +const title = defaultSettings.title || '认证系统' + +export default function getPageTitle(pageTitle) { + if (pageTitle) { + return `${pageTitle} - ${title}` + } + return `${title}` +} diff --git a/hb_client/src/utils/index.js b/hb_client/src/utils/index.js new file mode 100644 index 0000000..722c202 --- /dev/null +++ b/hb_client/src/utils/index.js @@ -0,0 +1,384 @@ +/** + * Created by PanJiaChen on 16/11/18. + */ + +/** + * Parse the time to string + * @param {(Object|string|number)} time + * @param {string} cFormat + * @returns {string | null} + */ +export function parseTime(time, cFormat) { + if (arguments.length === 0) { + return null + } + const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}' + let date + if (typeof time === 'object') { + date = time + } else { + if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) { + time = parseInt(time) + } + if ((typeof time === 'number') && (time.toString().length === 10)) { + time = time * 1000 + } + date = new Date(time) + } + const formatObj = { + y: date.getFullYear(), + m: date.getMonth() + 1, + d: date.getDate(), + h: date.getHours(), + i: date.getMinutes(), + s: date.getSeconds(), + a: date.getDay() + } + const time_str = format.replace(/{([ymdhisa])+}/g, (result, key) => { + const value = formatObj[key] + // Note: getDay() returns 0 on Sunday + if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] } + return value.toString().padStart(2, '0') + }) + return time_str +} + +/** + * @param {number} time + * @param {string} option + * @returns {string} + */ +export function formatTime(time, option) { + if (('' + time).length === 10) { + time = parseInt(time) * 1000 + } else { + time = +time + } + const d = new Date(time) + const now = Date.now() + + const diff = (now - d) / 1000 + + if (diff < 30) { + return '刚刚' + } else if (diff < 3600) { + // less 1 hour + return Math.ceil(diff / 60) + '分钟前' + } else if (diff < 3600 * 24) { + return Math.ceil(diff / 3600) + '小时前' + } else if (diff < 3600 * 24 * 2) { + return '1天前' + } + if (option) { + return parseTime(time, option) + } else { + return ( + d.getMonth() + + 1 + + '月' + + d.getDate() + + '日' + + d.getHours() + + '时' + + d.getMinutes() + + '分' + ) + } +} + +/** + * @param {string} url + * @returns {Object} + */ +export function getQueryObject(url) { + url = url == null ? window.location.href : url + const search = url.substring(url.lastIndexOf('?') + 1) + const obj = {} + const reg = /([^?&=]+)=([^?&=]*)/g + search.replace(reg, (rs, $1, $2) => { + const name = decodeURIComponent($1) + let val = decodeURIComponent($2) + val = String(val) + obj[name] = val + return rs + }) + return obj +} + +/** + * @param {string} input value + * @returns {number} output value + */ +export function byteLength(str) { + // returns the byte length of an utf8 string + let s = str.length + for (var i = str.length - 1; i >= 0; i--) { + const code = str.charCodeAt(i) + if (code > 0x7f && code <= 0x7ff) s++ + else if (code > 0x7ff && code <= 0xffff) s += 2 + if (code >= 0xDC00 && code <= 0xDFFF) i-- + } + return s +} + +/** + * @param {Array} actual + * @returns {Array} + */ +export function cleanArray(actual) { + const newArray = [] + for (let i = 0; i < actual.length; i++) { + if (actual[i]) { + newArray.push(actual[i]) + } + } + return newArray +} + +/** + * @param {Object} json + * @returns {Array} + */ +export function param(json) { + if (!json) return '' + return cleanArray( + Object.keys(json).map(key => { + if (json[key] === undefined) return '' + return encodeURIComponent(key) + '=' + encodeURIComponent(json[key]) + }) + ).join('&') +} + +/** + * @param {string} url + * @returns {Object} + */ +export function param2Obj(url) { + const search = url.split('?')[1] + if (!search) { + return {} + } + return JSON.parse( + '{"' + + decodeURIComponent(search) + .replace(/"/g, '\\"') + .replace(/&/g, '","') + .replace(/=/g, '":"') + .replace(/\+/g, ' ') + + '"}' + ) +} + +/** + * @param {string} val + * @returns {string} + */ +export function html2Text(val) { + const div = document.createElement('div') + div.innerHTML = val + return div.textContent || div.innerText +} + +/** + * Merges two objects, giving the last one precedence + * @param {Object} target + * @param {(Object|Array)} source + * @returns {Object} + */ +export function objectMerge(target, source) { + if (typeof target !== 'object') { + target = {} + } + if (Array.isArray(source)) { + return source.slice() + } + Object.keys(source).forEach(property => { + const sourceProperty = source[property] + if (typeof sourceProperty === 'object') { + target[property] = objectMerge(target[property], sourceProperty) + } else { + target[property] = sourceProperty + } + }) + return target +} + +/** + * @param {HTMLElement} element + * @param {string} className + */ +export function toggleClass(element, className) { + if (!element || !className) { + return + } + let classString = element.className + const nameIndex = classString.indexOf(className) + if (nameIndex === -1) { + classString += '' + className + } else { + classString = + classString.substr(0, nameIndex) + + classString.substr(nameIndex + className.length) + } + element.className = classString +} + +/** + * @param {string} type + * @returns {Date} + */ +export function getTime(type) { + if (type === 'start') { + return new Date().getTime() - 3600 * 1000 * 24 * 90 + } else { + return new Date(new Date().toDateString()) + } +} + +/** + * @param {Function} func + * @param {number} wait + * @param {boolean} immediate + * @return {*} + */ +export function debounce(func, wait, immediate) { + let timeout, args, context, timestamp, result + + const later = function() { + // 据上一次触发时间间隔 + const last = +new Date() - timestamp + + // 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait + if (last < wait && last > 0) { + timeout = setTimeout(later, wait - last) + } else { + timeout = null + // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用 + if (!immediate) { + result = func.apply(context, args) + if (!timeout) context = args = null + } + } + } + + return function(...args) { + context = this + timestamp = +new Date() + const callNow = immediate && !timeout + // 如果延时不存在,重新设定延时 + if (!timeout) timeout = setTimeout(later, wait) + if (callNow) { + result = func.apply(context, args) + context = args = null + } + + return result + } +} + +/** + * This is just a simple version of deep copy + * Has a lot of edge cases bug + * If you want to use a perfect deep copy, use lodash's _.cloneDeep + * @param {Object} source + * @returns {Object} + */ +export function deepClone(source) { + if (!source && typeof source !== 'object') { + throw new Error('error arguments', 'deepClone') + } + const targetObj = source.constructor === Array ? [] : {} + Object.keys(source).forEach(keys => { + if (source[keys] && typeof source[keys] === 'object') { + targetObj[keys] = deepClone(source[keys]) + } else { + targetObj[keys] = source[keys] + } + }) + return targetObj +} + +/** + * @param {Array} arr + * @returns {Array} + */ +export function uniqueArr(arr) { + return Array.from(new Set(arr)) +} + +/** + * @returns {string} + */ +export function createUniqueString() { + const timestamp = +new Date() + '' + const randomNum = parseInt((1 + Math.random()) * 65536) + '' + return (+(randomNum + timestamp)).toString(32) +} + +/** + * Check if an element has a class + * @param {HTMLElement} elm + * @param {string} cls + * @returns {boolean} + */ +export function hasClass(ele, cls) { + return !!ele.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)')) +} + +/** + * Add class to element + * @param {HTMLElement} elm + * @param {string} cls + */ +export function addClass(ele, cls) { + if (!hasClass(ele, cls)) ele.className += ' ' + cls +} + +/** + * Remove class from element + * @param {HTMLElement} elm + * @param {string} cls + */ +export function removeClass(ele, cls) { + if (hasClass(ele, cls)) { + const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)') + ele.className = ele.className.replace(reg, ' ') + } +} + +export function genTree(data) { + const result = [] + if (!Array.isArray(data)) { + return result + } + data.forEach(item => { + delete item.children + }) + const map = {} + data.forEach(item => { + item.label = item.name + if(item.fullname){ + item.label = item.fullname + } + item.value = item.id + map[item.id] = item + }) + data.forEach(item => { + const parent = map[item.parent] + if (parent) { + (parent.children || (parent.children = [])).push(item) + } else { + result.push(item) + } + }) + return result +} + +const arrChange = arr => arr.map(item => { + const res = {} + for (const key in item) { + const _key = key === 'name' ? 'label' : key + res[_key] = Array.isArray(item[key]) ? arrChange(item[key]) : item[key] + } + return res +}) diff --git a/hb_client/src/utils/permission.js b/hb_client/src/utils/permission.js new file mode 100644 index 0000000..217bdeb --- /dev/null +++ b/hb_client/src/utils/permission.js @@ -0,0 +1,27 @@ +import store from '@/store' + +/** + * @param {Array} value + * @returns {Boolean} + * @example see @/views/permission/directive.vue + */ +export default function checkPermission(value) { + if (value && value instanceof Array && value.length > 0) { + const perms = store.getters && store.getters.perms + const permissionperms = value + if (perms.includes('admin')) { + return true + } // 如果是超管,都可以操作 + const hasPermission = perms.some(perm => { + return permissionperms.includes(perm) + }) + + if (!hasPermission) { + return false + } + return true + } else { + console.error(`need perms! Like v-permission="['admin','editor']"`) + return false + } +} diff --git a/hb_client/src/utils/request.js b/hb_client/src/utils/request.js new file mode 100644 index 0000000..da72b1a --- /dev/null +++ b/hb_client/src/utils/request.js @@ -0,0 +1,88 @@ +import axios from 'axios' +import { MessageBox, Message } from 'element-ui' +import store from '@/store' +import { getToken } from '@/utils/auth' + +// create an axios instance +const service = axios.create({ + baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url + // withCredentials: true, // send cookies when cross-domain requests + timeout: 10000 // request timeout +}) + +// request interceptor +service.interceptors.request.use( + config => { + // do something before request is sent + if (store.getters.token) { + // let each request carry token + // ['X-Token'] is a custom headers key + // please modify it according to the actual situation + config.headers['Authorization'] = 'Bearer ' + getToken() + } + return config + }, + error => { + // do something with request error + // console.log(error) // for debug + return Promise.reject(error) + } +) + +// response interceptor +service.interceptors.response.use( + /** + * If you want to get http information such as headers or status + * Please return response => response + */ + + /** + * Determine the request status by custom code + * Here is just an example + * You can also judge the status by HTTP Status Code + */ + response => { + const res = response.data + if(res.code>=200 && res.code<400){ + return res + } + if (res.code === 401) { + if(res.msg.indexOf('No active account')!=-1){ + Message({ + message: '用户名或密码错误', + type: 'error', + duration: 3 * 1000 + }) + }else{ + MessageBox.confirm('认证失败,请重新登陆.', '确认退出', { + confirmButtonText: '重新登陆', + cancelButtonText: '取消', + type: 'warning' + }).then(() => { + store.dispatch('user/resetToken').then(() => { + location.reload() + }) + }) + } + + } else if (res.code >= 400) { + Message({ + message: res.msg || '请求出错', + type: 'error', + duration: 3 * 1000 + }) + return Promise.reject(new Error(res.msg || '请求出错')) + } + }, + error => { + // console.log(error,response) // for debug + Message({ + message: "服务器错误", + type: 'error', + duration: 5 * 1000 + }) + return Promise.reject(error) + } +) + +export default service diff --git a/hb_client/src/utils/scroll-to.js b/hb_client/src/utils/scroll-to.js new file mode 100644 index 0000000..c5d8e04 --- /dev/null +++ b/hb_client/src/utils/scroll-to.js @@ -0,0 +1,58 @@ +Math.easeInOutQuad = function(t, b, c, d) { + t /= d / 2 + if (t < 1) { + return c / 2 * t * t + b + } + t-- + return -c / 2 * (t * (t - 2) - 1) + b +} + +// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts +var requestAnimFrame = (function() { + return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) } +})() + +/** + * Because it's so fucking difficult to detect the scrolling element, just move them all + * @param {number} amount + */ +function move(amount) { + document.documentElement.scrollTop = amount + document.body.parentNode.scrollTop = amount + document.body.scrollTop = amount +} + +function position() { + return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop +} + +/** + * @param {number} to + * @param {number} duration + * @param {Function} callback + */ +export function scrollTo(to, duration, callback) { + const start = position() + const change = to - start + const increment = 20 + let currentTime = 0 + duration = (typeof (duration) === 'undefined') ? 500 : duration + var animateScroll = function() { + // increment the time + currentTime += increment + // find the value with the quadratic in-out easing function + var val = Math.easeInOutQuad(currentTime, start, change, duration) + // move the document.body + move(val) + // do the animation unless its over + if (currentTime < duration) { + requestAnimFrame(animateScroll) + } else { + if (callback && typeof (callback) === 'function') { + // the animation is done so lets callback + callback() + } + } + } + animateScroll() +} diff --git a/hb_client/src/utils/validate.js b/hb_client/src/utils/validate.js new file mode 100644 index 0000000..8d962ad --- /dev/null +++ b/hb_client/src/utils/validate.js @@ -0,0 +1,20 @@ +/** + * Created by PanJiaChen on 16/11/18. + */ + +/** + * @param {string} path + * @returns {Boolean} + */ +export function isExternal(path) { + return /^(https?:|mailto:|tel:)/.test(path) +} + +/** + * @param {string} str + * @returns {Boolean} + */ +export function validUsername(str) { + const valid_map = ['admin', 'editor'] + return valid_map.indexOf(str.trim()) >= 0 +} diff --git a/hb_client/src/vendor/Export2Excel.js b/hb_client/src/vendor/Export2Excel.js new file mode 100644 index 0000000..d8a2af3 --- /dev/null +++ b/hb_client/src/vendor/Export2Excel.js @@ -0,0 +1,220 @@ +/* eslint-disable */ +import { saveAs } from 'file-saver' +import XLSX from 'xlsx' + +function generateArray(table) { + var out = []; + var rows = table.querySelectorAll('tr'); + var ranges = []; + for (var R = 0; R < rows.length; ++R) { + var outRow = []; + var row = rows[R]; + var columns = row.querySelectorAll('td'); + for (var C = 0; C < columns.length; ++C) { + var cell = columns[C]; + var colspan = cell.getAttribute('colspan'); + var rowspan = cell.getAttribute('rowspan'); + var cellValue = cell.innerText; + if (cellValue !== "" && cellValue == +cellValue) cellValue = +cellValue; + + //Skip ranges + ranges.forEach(function (range) { + if (R >= range.s.r && R <= range.e.r && outRow.length >= range.s.c && outRow.length <= range.e.c) { + for (var i = 0; i <= range.e.c - range.s.c; ++i) outRow.push(null); + } + }); + + //Handle Row Span + if (rowspan || colspan) { + rowspan = rowspan || 1; + colspan = colspan || 1; + ranges.push({ + s: { + r: R, + c: outRow.length + }, + e: { + r: R + rowspan - 1, + c: outRow.length + colspan - 1 + } + }); + }; + + //Handle Value + outRow.push(cellValue !== "" ? cellValue : null); + + //Handle Colspan + if (colspan) + for (var k = 0; k < colspan - 1; ++k) outRow.push(null); + } + out.push(outRow); + } + return [out, ranges]; +}; + +function datenum(v, date1904) { + if (date1904) v += 1462; + var epoch = Date.parse(v); + return (epoch - new Date(Date.UTC(1899, 11, 30))) / (24 * 60 * 60 * 1000); +} + +function sheet_from_array_of_arrays(data, opts) { + var ws = {}; + var range = { + s: { + c: 10000000, + r: 10000000 + }, + e: { + c: 0, + r: 0 + } + }; + for (var R = 0; R != data.length; ++R) { + for (var C = 0; C != data[R].length; ++C) { + if (range.s.r > R) range.s.r = R; + if (range.s.c > C) range.s.c = C; + if (range.e.r < R) range.e.r = R; + if (range.e.c < C) range.e.c = C; + var cell = { + v: data[R][C] + }; + if (cell.v == null) continue; + var cell_ref = XLSX.utils.encode_cell({ + c: C, + r: R + }); + + if (typeof cell.v === 'number') cell.t = 'n'; + else if (typeof cell.v === 'boolean') cell.t = 'b'; + else if (cell.v instanceof Date) { + cell.t = 'n'; + cell.z = XLSX.SSF._table[14]; + cell.v = datenum(cell.v); + } else cell.t = 's'; + + ws[cell_ref] = cell; + } + } + if (range.s.c < 10000000) ws['!ref'] = XLSX.utils.encode_range(range); + return ws; +} + +function Workbook() { + if (!(this instanceof Workbook)) return new Workbook(); + this.SheetNames = []; + this.Sheets = {}; +} + +function s2ab(s) { + var buf = new ArrayBuffer(s.length); + var view = new Uint8Array(buf); + for (var i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF; + return buf; +} + +export function export_table_to_excel(id) { + var theTable = document.getElementById(id); + var oo = generateArray(theTable); + var ranges = oo[1]; + + /* original data */ + var data = oo[0]; + var ws_name = "SheetJS"; + + var wb = new Workbook(), + ws = sheet_from_array_of_arrays(data); + + /* add ranges to worksheet */ + // ws['!cols'] = ['apple', 'banan']; + ws['!merges'] = ranges; + + /* add worksheet to workbook */ + wb.SheetNames.push(ws_name); + wb.Sheets[ws_name] = ws; + + var wbout = XLSX.write(wb, { + bookType: 'xlsx', + bookSST: false, + type: 'binary' + }); + + saveAs(new Blob([s2ab(wbout)], { + type: "application/octet-stream" + }), "test.xlsx") +} + +export function export_json_to_excel({ + multiHeader = [], + header, + data, + filename, + merges = [], + autoWidth = true, + bookType = 'xlsx' +} = {}) { + /* original data */ + filename = filename || 'excel-list' + data = [...data] + data.unshift(header); + + for (let i = multiHeader.length - 1; i > -1; i--) { + data.unshift(multiHeader[i]) + } + + var ws_name = "SheetJS"; + var wb = new Workbook(), + ws = sheet_from_array_of_arrays(data); + + if (merges.length > 0) { + if (!ws['!merges']) ws['!merges'] = []; + merges.forEach(item => { + ws['!merges'].push(XLSX.utils.decode_range(item)) + }) + } + + if (autoWidth) { + /*设置worksheet每列的最大宽度*/ + const colWidth = data.map(row => row.map(val => { + /*先判断是否为null/undefined*/ + if (val == null) { + return { + 'wch': 10 + }; + } + /*再判断是否为中文*/ + else if (val.toString().charCodeAt(0) > 255) { + return { + 'wch': val.toString().length * 2 + }; + } else { + return { + 'wch': val.toString().length + }; + } + })) + /*以第一行为初始值*/ + let result = colWidth[0]; + for (let i = 1; i < colWidth.length; i++) { + for (let j = 0; j < colWidth[i].length; j++) { + if (result[j]['wch'] < colWidth[i][j]['wch']) { + result[j]['wch'] = colWidth[i][j]['wch']; + } + } + } + ws['!cols'] = result; + } + + /* add worksheet to workbook */ + wb.SheetNames.push(ws_name); + wb.Sheets[ws_name] = ws; + + var wbout = XLSX.write(wb, { + bookType: bookType, + bookSST: false, + type: 'binary' + }); + saveAs(new Blob([s2ab(wbout)], { + type: "application/octet-stream" + }), `${filename}.${bookType}`); +} diff --git a/hb_client/src/vendor/Export2Zip.js b/hb_client/src/vendor/Export2Zip.js new file mode 100644 index 0000000..db70707 --- /dev/null +++ b/hb_client/src/vendor/Export2Zip.js @@ -0,0 +1,24 @@ +/* eslint-disable */ +import { saveAs } from 'file-saver' +import JSZip from 'jszip' + +export function export_txt_to_zip(th, jsonData, txtName, zipName) { + const zip = new JSZip() + const txt_name = txtName || 'file' + const zip_name = zipName || 'file' + const data = jsonData + let txtData = `${th}\r\n` + data.forEach((row) => { + let tempStr = '' + tempStr = row.toString() + txtData += `${tempStr}\r\n` + }) + zip.file(`${txt_name}.txt`, txtData) + zip.generateAsync({ + type: "blob" + }).then((blob) => { + saveAs(blob, `${zip_name}.zip`) + }, (err) => { + alert('导出失败') + }) +} diff --git a/hb_client/src/views/404.vue b/hb_client/src/views/404.vue new file mode 100644 index 0000000..18eda34 --- /dev/null +++ b/hb_client/src/views/404.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/hb_client/src/views/dashboard/index.vue b/hb_client/src/views/dashboard/index.vue new file mode 100644 index 0000000..bfd075e --- /dev/null +++ b/hb_client/src/views/dashboard/index.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/hb_client/src/views/form/index.vue b/hb_client/src/views/form/index.vue new file mode 100644 index 0000000..f4d66d3 --- /dev/null +++ b/hb_client/src/views/form/index.vue @@ -0,0 +1,85 @@ + + + + + + diff --git a/hb_client/src/views/login/index.vue b/hb_client/src/views/login/index.vue new file mode 100644 index 0000000..2568652 --- /dev/null +++ b/hb_client/src/views/login/index.vue @@ -0,0 +1,237 @@ + + + + + + + diff --git a/hb_client/src/views/nested/menu1/index.vue b/hb_client/src/views/nested/menu1/index.vue new file mode 100644 index 0000000..30cb670 --- /dev/null +++ b/hb_client/src/views/nested/menu1/index.vue @@ -0,0 +1,7 @@ + diff --git a/hb_client/src/views/nested/menu1/menu1-1/index.vue b/hb_client/src/views/nested/menu1/menu1-1/index.vue new file mode 100644 index 0000000..27e173a --- /dev/null +++ b/hb_client/src/views/nested/menu1/menu1-1/index.vue @@ -0,0 +1,7 @@ + diff --git a/hb_client/src/views/nested/menu1/menu1-2/index.vue b/hb_client/src/views/nested/menu1/menu1-2/index.vue new file mode 100644 index 0000000..0c86276 --- /dev/null +++ b/hb_client/src/views/nested/menu1/menu1-2/index.vue @@ -0,0 +1,7 @@ + diff --git a/hb_client/src/views/nested/menu1/menu1-2/menu1-2-1/index.vue b/hb_client/src/views/nested/menu1/menu1-2/menu1-2-1/index.vue new file mode 100644 index 0000000..f87d88f --- /dev/null +++ b/hb_client/src/views/nested/menu1/menu1-2/menu1-2-1/index.vue @@ -0,0 +1,5 @@ + diff --git a/hb_client/src/views/nested/menu1/menu1-2/menu1-2-2/index.vue b/hb_client/src/views/nested/menu1/menu1-2/menu1-2-2/index.vue new file mode 100644 index 0000000..d88789f --- /dev/null +++ b/hb_client/src/views/nested/menu1/menu1-2/menu1-2-2/index.vue @@ -0,0 +1,5 @@ + diff --git a/hb_client/src/views/nested/menu1/menu1-3/index.vue b/hb_client/src/views/nested/menu1/menu1-3/index.vue new file mode 100644 index 0000000..f7cd073 --- /dev/null +++ b/hb_client/src/views/nested/menu1/menu1-3/index.vue @@ -0,0 +1,5 @@ + diff --git a/hb_client/src/views/nested/menu2/index.vue b/hb_client/src/views/nested/menu2/index.vue new file mode 100644 index 0000000..19dd48f --- /dev/null +++ b/hb_client/src/views/nested/menu2/index.vue @@ -0,0 +1,5 @@ + diff --git a/hb_client/src/views/system/changepassword.vue b/hb_client/src/views/system/changepassword.vue new file mode 100644 index 0000000..165c218 --- /dev/null +++ b/hb_client/src/views/system/changepassword.vue @@ -0,0 +1,78 @@ + + \ No newline at end of file diff --git a/hb_client/src/views/system/dict.vue b/hb_client/src/views/system/dict.vue new file mode 100644 index 0000000..273d8a0 --- /dev/null +++ b/hb_client/src/views/system/dict.vue @@ -0,0 +1,370 @@ + + + diff --git a/hb_client/src/views/system/file.vue b/hb_client/src/views/system/file.vue new file mode 100644 index 0000000..b9c70a8 --- /dev/null +++ b/hb_client/src/views/system/file.vue @@ -0,0 +1,134 @@ + + diff --git a/hb_client/src/views/system/organization.vue b/hb_client/src/views/system/organization.vue new file mode 100644 index 0000000..a71f2b6 --- /dev/null +++ b/hb_client/src/views/system/organization.vue @@ -0,0 +1,225 @@ + + + diff --git a/hb_client/src/views/system/perm.vue b/hb_client/src/views/system/perm.vue new file mode 100644 index 0000000..ae8a7cf --- /dev/null +++ b/hb_client/src/views/system/perm.vue @@ -0,0 +1,239 @@ + + + diff --git a/hb_client/src/views/system/position.vue b/hb_client/src/views/system/position.vue new file mode 100644 index 0000000..cffc146 --- /dev/null +++ b/hb_client/src/views/system/position.vue @@ -0,0 +1,211 @@ + + + diff --git a/hb_client/src/views/system/role.vue b/hb_client/src/views/system/role.vue new file mode 100644 index 0000000..a19ebd0 --- /dev/null +++ b/hb_client/src/views/system/role.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/hb_client/src/views/system/task.vue b/hb_client/src/views/system/task.vue new file mode 100644 index 0000000..62797e9 --- /dev/null +++ b/hb_client/src/views/system/task.vue @@ -0,0 +1,410 @@ + + diff --git a/hb_client/src/views/system/user.vue b/hb_client/src/views/system/user.vue new file mode 100644 index 0000000..6e33e4d --- /dev/null +++ b/hb_client/src/views/system/user.vue @@ -0,0 +1,378 @@ + + + diff --git a/hb_client/src/views/table/index.vue b/hb_client/src/views/table/index.vue new file mode 100644 index 0000000..a1ed847 --- /dev/null +++ b/hb_client/src/views/table/index.vue @@ -0,0 +1,79 @@ + + + diff --git a/hb_client/src/views/tree/index.vue b/hb_client/src/views/tree/index.vue new file mode 100644 index 0000000..89c6b01 --- /dev/null +++ b/hb_client/src/views/tree/index.vue @@ -0,0 +1,78 @@ + + + + diff --git a/hb_client/tests/unit/.eslintrc.js b/hb_client/tests/unit/.eslintrc.js new file mode 100644 index 0000000..958d51b --- /dev/null +++ b/hb_client/tests/unit/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + env: { + jest: true + } +} diff --git a/hb_client/tests/unit/components/Breadcrumb.spec.js b/hb_client/tests/unit/components/Breadcrumb.spec.js new file mode 100644 index 0000000..1d94c8f --- /dev/null +++ b/hb_client/tests/unit/components/Breadcrumb.spec.js @@ -0,0 +1,98 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import VueRouter from 'vue-router' +import ElementUI from 'element-ui' +import Breadcrumb from '@/components/Breadcrumb/index.vue' + +const localVue = createLocalVue() +localVue.use(VueRouter) +localVue.use(ElementUI) + +const routes = [ + { + path: '/', + name: 'home', + children: [{ + path: 'dashboard', + name: 'dashboard' + }] + }, + { + path: '/menu', + name: 'menu', + children: [{ + path: 'menu1', + name: 'menu1', + meta: { title: 'menu1' }, + children: [{ + path: 'menu1-1', + name: 'menu1-1', + meta: { title: 'menu1-1' } + }, + { + path: 'menu1-2', + name: 'menu1-2', + redirect: 'noredirect', + meta: { title: 'menu1-2' }, + children: [{ + path: 'menu1-2-1', + name: 'menu1-2-1', + meta: { title: 'menu1-2-1' } + }, + { + path: 'menu1-2-2', + name: 'menu1-2-2' + }] + }] + }] + }] + +const router = new VueRouter({ + routes +}) + +describe('Breadcrumb.vue', () => { + const wrapper = mount(Breadcrumb, { + localVue, + router + }) + it('dashboard', () => { + router.push('/dashboard') + const len = wrapper.findAll('.el-breadcrumb__inner').length + expect(len).toBe(1) + }) + it('normal route', () => { + router.push('/menu/menu1') + const len = wrapper.findAll('.el-breadcrumb__inner').length + expect(len).toBe(2) + }) + it('nested route', () => { + router.push('/menu/menu1/menu1-2/menu1-2-1') + const len = wrapper.findAll('.el-breadcrumb__inner').length + expect(len).toBe(4) + }) + it('no meta.title', () => { + router.push('/menu/menu1/menu1-2/menu1-2-2') + const len = wrapper.findAll('.el-breadcrumb__inner').length + expect(len).toBe(3) + }) + // it('click link', () => { + // router.push('/menu/menu1/menu1-2/menu1-2-2') + // const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner') + // const second = breadcrumbArray.at(1) + // console.log(breadcrumbArray) + // const href = second.find('a').attributes().href + // expect(href).toBe('#/menu/menu1') + // }) + // it('noRedirect', () => { + // router.push('/menu/menu1/menu1-2/menu1-2-1') + // const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner') + // const redirectBreadcrumb = breadcrumbArray.at(2) + // expect(redirectBreadcrumb.contains('a')).toBe(false) + // }) + it('last breadcrumb', () => { + router.push('/menu/menu1/menu1-2/menu1-2-1') + const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner') + const redirectBreadcrumb = breadcrumbArray.at(3) + expect(redirectBreadcrumb.contains('a')).toBe(false) + }) +}) diff --git a/hb_client/tests/unit/components/Hamburger.spec.js b/hb_client/tests/unit/components/Hamburger.spec.js new file mode 100644 index 0000000..01ea303 --- /dev/null +++ b/hb_client/tests/unit/components/Hamburger.spec.js @@ -0,0 +1,18 @@ +import { shallowMount } from '@vue/test-utils' +import Hamburger from '@/components/Hamburger/index.vue' +describe('Hamburger.vue', () => { + it('toggle click', () => { + const wrapper = shallowMount(Hamburger) + const mockFn = jest.fn() + wrapper.vm.$on('toggleClick', mockFn) + wrapper.find('.hamburger').trigger('click') + expect(mockFn).toBeCalled() + }) + it('prop isActive', () => { + const wrapper = shallowMount(Hamburger) + wrapper.setProps({ isActive: true }) + expect(wrapper.contains('.is-active')).toBe(true) + wrapper.setProps({ isActive: false }) + expect(wrapper.contains('.is-active')).toBe(false) + }) +}) diff --git a/hb_client/tests/unit/components/SvgIcon.spec.js b/hb_client/tests/unit/components/SvgIcon.spec.js new file mode 100644 index 0000000..31467a9 --- /dev/null +++ b/hb_client/tests/unit/components/SvgIcon.spec.js @@ -0,0 +1,22 @@ +import { shallowMount } from '@vue/test-utils' +import SvgIcon from '@/components/SvgIcon/index.vue' +describe('SvgIcon.vue', () => { + it('iconClass', () => { + const wrapper = shallowMount(SvgIcon, { + propsData: { + iconClass: 'test' + } + }) + expect(wrapper.find('use').attributes().href).toBe('#icon-test') + }) + it('className', () => { + const wrapper = shallowMount(SvgIcon, { + propsData: { + iconClass: 'test' + } + }) + expect(wrapper.classes().length).toBe(1) + wrapper.setProps({ className: 'test' }) + expect(wrapper.classes().includes('test')).toBe(true) + }) +}) diff --git a/hb_client/tests/unit/utils/formatTime.spec.js b/hb_client/tests/unit/utils/formatTime.spec.js new file mode 100644 index 0000000..24e165b --- /dev/null +++ b/hb_client/tests/unit/utils/formatTime.spec.js @@ -0,0 +1,30 @@ +import { formatTime } from '@/utils/index.js' + +describe('Utils:formatTime', () => { + const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01" + const retrofit = 5 * 1000 + + it('ten digits timestamp', () => { + expect(formatTime((d / 1000).toFixed(0))).toBe('7月13日17时54分') + }) + it('test now', () => { + expect(formatTime(+new Date() - 1)).toBe('刚刚') + }) + it('less two minute', () => { + expect(formatTime(+new Date() - 60 * 2 * 1000 + retrofit)).toBe('2分钟前') + }) + it('less two hour', () => { + expect(formatTime(+new Date() - 60 * 60 * 2 * 1000 + retrofit)).toBe('2小时前') + }) + it('less one day', () => { + expect(formatTime(+new Date() - 60 * 60 * 24 * 1 * 1000)).toBe('1天前') + }) + it('more than one day', () => { + expect(formatTime(d)).toBe('7月13日17时54分') + }) + it('format', () => { + expect(formatTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54') + expect(formatTime(d, '{y}-{m}-{d}')).toBe('2018-07-13') + expect(formatTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54') + }) +}) diff --git a/hb_client/tests/unit/utils/parseTime.spec.js b/hb_client/tests/unit/utils/parseTime.spec.js new file mode 100644 index 0000000..41d1b02 --- /dev/null +++ b/hb_client/tests/unit/utils/parseTime.spec.js @@ -0,0 +1,28 @@ +import { parseTime } from '@/utils/index.js' + +describe('Utils:parseTime', () => { + const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01" + it('timestamp', () => { + expect(parseTime(d)).toBe('2018-07-13 17:54:01') + }) + it('ten digits timestamp', () => { + expect(parseTime((d / 1000).toFixed(0))).toBe('2018-07-13 17:54:01') + }) + it('new Date', () => { + expect(parseTime(new Date(d))).toBe('2018-07-13 17:54:01') + }) + it('format', () => { + expect(parseTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54') + expect(parseTime(d, '{y}-{m}-{d}')).toBe('2018-07-13') + expect(parseTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54') + }) + it('get the day of the week', () => { + expect(parseTime(d, '{a}')).toBe('五') // 星期五 + }) + it('get the day of the week', () => { + expect(parseTime(+d + 1000 * 60 * 60 * 24 * 2, '{a}')).toBe('日') // 星期日 + }) + it('empty argument', () => { + expect(parseTime()).toBeNull() + }) +}) diff --git a/hb_client/tests/unit/utils/validate.spec.js b/hb_client/tests/unit/utils/validate.spec.js new file mode 100644 index 0000000..f774905 --- /dev/null +++ b/hb_client/tests/unit/utils/validate.spec.js @@ -0,0 +1,17 @@ +import { validUsername, isExternal } from '@/utils/validate.js' + +describe('Utils:validate', () => { + it('validUsername', () => { + expect(validUsername('admin')).toBe(true) + expect(validUsername('editor')).toBe(true) + expect(validUsername('xxxx')).toBe(false) + }) + it('isExternal', () => { + expect(isExternal('https://github.com/PanJiaChen/vue-element-admin')).toBe(true) + expect(isExternal('http://github.com/PanJiaChen/vue-element-admin')).toBe(true) + expect(isExternal('github.com/PanJiaChen/vue-element-admin')).toBe(false) + expect(isExternal('/dashboard')).toBe(false) + expect(isExternal('./dashboard')).toBe(false) + expect(isExternal('dashboard')).toBe(false) + }) +}) diff --git a/hb_client/vue.config.js b/hb_client/vue.config.js new file mode 100644 index 0000000..a469d28 --- /dev/null +++ b/hb_client/vue.config.js @@ -0,0 +1,128 @@ +'use strict' +const path = require('path') +const defaultSettings = require('./src/settings.js') + +function resolve(dir) { + return path.join(__dirname, dir) +} + +const name = defaultSettings.title || 'vue Admin Template' // page title + +// If your port is set to 80, +// use administrator privileges to execute the command line. +// For example, Mac: sudo npm run +// You can change the port by the following methods: +// port = 9528 npm run dev OR npm run dev --port = 9528 +const port = process.env.port || process.env.npm_config_port || 9528 // dev port + +// All configuration item explanations can be find in https://cli.vuejs.org/config/ +module.exports = { + /** + * You will need to set publicPath if you plan to deploy your site under a sub path, + * for example GitHub Pages. If you plan to deploy your site to https://foo.github.io/bar/, + * then publicPath should be set to "/bar/". + * In most cases please use '/' !!! + * Detail: https://cli.vuejs.org/config/#publicpath + */ + publicPath: '/', + outputDir: 'dist', + assetsDir: 'static', + lintOnSave: false, //process.env.NODE_ENV === 'development', + productionSourceMap: false, + devServer: { + port: port, + open: true, + overlay: { + warnings: false, + errors: true + }, + before: require('./mock/mock-server.js') + }, + configureWebpack: { + // provide the app's title in webpack's name field, so that + // it can be accessed in index.html to inject the correct title. + name: name, + resolve: { + alias: { + '@': resolve('src') + } + } + }, + chainWebpack(config) { + config.plugins.delete('preload') // TODO: need test + config.plugins.delete('prefetch') // TODO: need test + + // set svg-sprite-loader + config.module + .rule('svg') + .exclude.add(resolve('src/icons')) + .end() + config.module + .rule('icons') + .test(/\.svg$/) + .include.add(resolve('src/icons')) + .end() + .use('svg-sprite-loader') + .loader('svg-sprite-loader') + .options({ + symbolId: 'icon-[name]' + }) + .end() + + // set preserveWhitespace + config.module + .rule('vue') + .use('vue-loader') + .loader('vue-loader') + .tap(options => { + options.compilerOptions.preserveWhitespace = true + return options + }) + .end() + + config + // https://webpack.js.org/configuration/devtool/#development + .when(process.env.NODE_ENV === 'development', + config => config.devtool('cheap-source-map') + ) + + config + .when(process.env.NODE_ENV !== 'development', + config => { + config + .plugin('ScriptExtHtmlWebpackPlugin') + .after('html') + .use('script-ext-html-webpack-plugin', [{ + // `runtime` must same as runtimeChunk name. default is `runtime` + inline: /runtime\..*\.js$/ + }]) + .end() + config + .optimization.splitChunks({ + chunks: 'all', + cacheGroups: { + libs: { + name: 'chunk-libs', + test: /[\\/]node_modules[\\/]/, + priority: 10, + chunks: 'initial' // only package third parties that are initially dependent + }, + elementUI: { + name: 'chunk-elementUI', // split elementUI into a single package + priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app + test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm + }, + commons: { + name: 'chunk-commons', + test: resolve('src/components'), // can customize your rules + minChunks: 3, // minimum common number + priority: 5, + reuseExistingChunk: true + } + } + }) + config.optimization.runtimeChunk('single') + } + ) + } +} diff --git a/hb_server/.gitignore b/hb_server/.gitignore new file mode 100644 index 0000000..25ee7a4 --- /dev/null +++ b/hb_server/.gitignore @@ -0,0 +1,13 @@ +.vscode/ +.vs/ +venv/ +__pycache__/ +*.pyc +media/* +vuedist/* +!media/default/ +celerybeat.pid +celerybeat-schedule.bak +celerybeat-schedule.dat +celerybeat-schedule.dir +db.sqlite3 \ No newline at end of file diff --git a/hb_server/Dockerfile b/hb_server/Dockerfile new file mode 100644 index 0000000..d35db3b --- /dev/null +++ b/hb_server/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.8-slim +WORKDIR /code +ADD . . +RUN sed -i -re 's/(deb|security)\.debian\.org/mirrors.aliyun.com/g' /etc/apt/sources.list &&\ + apt-get update && apt-get install -y gcc libpq-dev default-libmysqlclient-dev &&\ + apt-get clean && rm -rf /var/lib/apt/lists/* &&\ + pip install --no-cache-dir --trusted-host mirrors.aliyun.com -i https://mirrors.aliyun.com/pypi/simple/ supervisor &&\ + pip install --no-cache-dir --trusted-host mirrors.aliyun.com -i https://mirrors.aliyun.com/pypi/simple/ -r ./requirements.txt +EXPOSE 80 +ENTRYPOINT ["/bin/bash","-C","/code/start.sh"] diff --git a/hb_server/apps/crm/__init__.py b/hb_server/apps/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hb_server/apps/crm/admin.py b/hb_server/apps/crm/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/hb_server/apps/crm/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/hb_server/apps/crm/apps.py b/hb_server/apps/crm/apps.py new file mode 100644 index 0000000..d6f91d2 --- /dev/null +++ b/hb_server/apps/crm/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CrmConfig(AppConfig): + name = 'crm' diff --git a/hb_server/apps/crm/migrations/__init__.py b/hb_server/apps/crm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hb_server/apps/crm/models.py b/hb_server/apps/crm/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/hb_server/apps/crm/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/hb_server/apps/crm/tests.py b/hb_server/apps/crm/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/hb_server/apps/crm/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/hb_server/apps/crm/views.py b/hb_server/apps/crm/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/hb_server/apps/crm/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/hb_server/apps/monitor/__init__.py b/hb_server/apps/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hb_server/apps/monitor/admin.py b/hb_server/apps/monitor/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/hb_server/apps/monitor/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/hb_server/apps/monitor/apps.py b/hb_server/apps/monitor/apps.py new file mode 100644 index 0000000..e49ebca --- /dev/null +++ b/hb_server/apps/monitor/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MonitorConfig(AppConfig): + name = 'apps.monitor' + verbose_name = '系统监控' diff --git a/hb_server/apps/monitor/middleware.py b/hb_server/apps/monitor/middleware.py new file mode 100644 index 0000000..038a432 --- /dev/null +++ b/hb_server/apps/monitor/middleware.py @@ -0,0 +1 @@ +from django.utils.deprecation import MiddlewareMixin diff --git a/hb_server/apps/monitor/migrations/__init__.py b/hb_server/apps/monitor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hb_server/apps/monitor/models.py b/hb_server/apps/monitor/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/hb_server/apps/monitor/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/hb_server/apps/monitor/tests.py b/hb_server/apps/monitor/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/hb_server/apps/monitor/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/hb_server/apps/monitor/urls.py b/hb_server/apps/monitor/urls.py new file mode 100644 index 0000000..73ae248 --- /dev/null +++ b/hb_server/apps/monitor/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework import routers +from .views import ServerInfoView, LogView, LogDetailView + + +urlpatterns = [ + path('log/', LogView.as_view()), + path('log//', LogDetailView.as_view()), + path('server/', ServerInfoView.as_view()), +] diff --git a/hb_server/apps/monitor/views.py b/hb_server/apps/monitor/views.py new file mode 100644 index 0000000..3fa290d --- /dev/null +++ b/hb_server/apps/monitor/views.py @@ -0,0 +1,63 @@ +from django.shortcuts import render +import psutil +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ViewSet +from django.conf import settings +import os +from rest_framework import serializers, status +# Create your views here. + +class ServerInfoView(APIView): + """ + 获取服务器状态信息 + """ + permission_classes = [IsAuthenticated] + def get(self, request, *args, **kwargs): + ret={'cpu':{}, 'memory':{}, 'disk':{}} + ret['cpu']['count'] = psutil.cpu_count() + ret['cpu']['lcount'] = psutil.cpu_count(logical=False) + ret['cpu']['percent'] = psutil.cpu_percent(interval=1) + memory = psutil.virtual_memory() + ret['memory']['total'] = round(memory.total/1024/1024/1024,2) + ret['memory']['used'] = round(memory.used/1024/1024/1024,2) + ret['memory']['percent'] = memory.percent + disk = psutil.disk_usage('/') + ret['disk']['total'] = round(disk.total/1024/1024/1024,2) + ret['disk']['used'] = round(disk.used/1024/1024/1024,2) + ret['disk']['percent'] = disk.percent + return Response(ret) + +class LogView(APIView): + + def get(self, request, *args, **kwargs): + """ + 查看最近的日志列表 + """ + logs =[] + for root, dirs, files in os.walk(settings.LOG_PATH): + for file in files: + if len(logs)>50:break + filepath = os.path.join(root, file) + fsize = os.path.getsize(filepath) + if fsize: + logs.append({ + "name":file, + "filepath":filepath, + "size":round(fsize/1000,1) + }) + return Response(logs) + +class LogDetailView(APIView): + + def get(self, request, name): + """ + 查看日志详情 + """ + try: + with open(os.path.join(settings.LOG_PATH, name)) as f: + data = f.read() + return Response(data) + except: + return Response('未找到', status=status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/hb_server/apps/system/__init__.py b/hb_server/apps/system/__init__.py new file mode 100644 index 0000000..f5317b9 --- /dev/null +++ b/hb_server/apps/system/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.system.apps.SystemConfig' \ No newline at end of file diff --git a/hb_server/apps/system/admin.py b/hb_server/apps/system/admin.py new file mode 100644 index 0000000..e47b0d7 --- /dev/null +++ b/hb_server/apps/system/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin +from .models import User, Organization, Role, Permission, DictType, Dict, File +# Register your models here. +admin.site.register(User) +admin.site.register(Organization) +admin.site.register(Role) +admin.site.register(Permission) +admin.site.register(DictType) +admin.site.register(Dict, SimpleHistoryAdmin) +admin.site.register(File) \ No newline at end of file diff --git a/hb_server/apps/system/apps.py b/hb_server/apps/system/apps.py new file mode 100644 index 0000000..dfd43d1 --- /dev/null +++ b/hb_server/apps/system/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class SystemConfig(AppConfig): + name = 'apps.system' + verbose_name = '系统管理' + + def ready(self): + import apps.system.signals \ No newline at end of file diff --git a/hb_server/apps/system/authentication.py b/hb_server/apps/system/authentication.py new file mode 100644 index 0000000..3c85943 --- /dev/null +++ b/hb_server/apps/system/authentication.py @@ -0,0 +1,23 @@ +from django.contrib.auth.backends import ModelBackend +from django.db.models import Q +from django.contrib.auth import get_user_model + +UserModel = get_user_model() + + +class CustomBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None: + username = kwargs.get(UserModel.USERNAME_FIELD) + if username is None or password is None: + return + try: + user = UserModel._default_manager.get( + Q(username=username) | Q(phone=username) | Q(email=username)) + except UserModel.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user (#20760). + UserModel().set_password(password) + else: + if user.check_password(password) and self.user_can_authenticate(user): + return user diff --git a/hb_server/apps/system/filters.py b/hb_server/apps/system/filters.py new file mode 100644 index 0000000..4199790 --- /dev/null +++ b/hb_server/apps/system/filters.py @@ -0,0 +1,11 @@ +from django_filters import rest_framework as filters +from .models import User + + +class UserFilter(filters.FilterSet): + class Meta: + model = User + fields = { + 'name': ['exact', 'contains'], + 'is_active': ['exact'], + } diff --git a/hb_server/apps/system/migrations/0001_initial.py b/hb_server/apps/system/migrations/0001_initial.py new file mode 100644 index 0000000..a53a840 --- /dev/null +++ b/hb_server/apps/system/migrations/0001_initial.py @@ -0,0 +1,239 @@ +# Generated by Django 3.0.7 on 2021-02-27 14:29 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import simple_history.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('name', models.CharField(blank=True, max_length=20, null=True, verbose_name='姓名')), + ('phone', models.CharField(blank=True, max_length=11, null=True, unique=True, verbose_name='手机号')), + ('avatar', models.CharField(blank=True, default='/media/default/avatar.png', max_length=100, null=True, verbose_name='头像')), + ], + options={ + 'verbose_name': '用户信息', + 'verbose_name_plural': '用户信息', + 'ordering': ['id'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Dict', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=60, verbose_name='名称')), + ('code', models.CharField(blank=True, max_length=30, null=True, verbose_name='编号')), + ('description', models.TextField(blank=True, null=True, verbose_name='描述')), + ('sort', models.IntegerField(default=1, verbose_name='排序')), + ('is_used', models.BooleanField(default=True, verbose_name='是否有效')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.Dict', verbose_name='父')), + ], + options={ + 'verbose_name': '字典', + 'verbose_name_plural': '字典', + }, + ), + migrations.CreateModel( + name='DictType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=30, verbose_name='名称')), + ('code', models.CharField(max_length=30, unique=True, verbose_name='代号')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.DictType', verbose_name='父')), + ], + options={ + 'verbose_name': '字典类型', + 'verbose_name_plural': '字典类型', + }, + ), + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=60, verbose_name='名称')), + ('type', models.CharField(choices=[('公司', '公司'), ('部门', '部门')], default='部门', max_length=20, verbose_name='类型')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.Organization', verbose_name='父')), + ], + options={ + 'verbose_name': '组织架构', + 'verbose_name_plural': '组织架构', + }, + ), + migrations.CreateModel( + name='Permission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=30, verbose_name='名称')), + ('type', models.CharField(choices=[('目录', '目录'), ('菜单', '菜单'), ('接口', '接口')], default='接口', max_length=20, verbose_name='类型')), + ('is_frame', models.BooleanField(default=False, verbose_name='外部链接')), + ('sort', models.IntegerField(default=1, verbose_name='排序标记')), + ('method', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='方法/代号')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.Permission', verbose_name='父')), + ], + options={ + 'verbose_name': '功能权限表', + 'verbose_name_plural': '功能权限表', + 'ordering': ['sort'], + }, + ), + migrations.CreateModel( + name='Position', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=32, unique=True, verbose_name='名称')), + ('description', models.CharField(blank=True, max_length=50, null=True, verbose_name='描述')), + ], + options={ + 'verbose_name': '职位/岗位', + 'verbose_name_plural': '职位/岗位', + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=32, unique=True, verbose_name='角色')), + ('datas', models.CharField(choices=[('全部', '全部'), ('自定义', '自定义'), ('同级及以下', '同级及以下'), ('本级及以下', '本级及以下'), ('本级', '本级'), ('仅本人', '仅本人')], default='本级及以下', max_length=50, verbose_name='数据权限')), + ('description', models.CharField(blank=True, max_length=50, null=True, verbose_name='描述')), + ('depts', models.ManyToManyField(blank=True, to='system.Organization', verbose_name='权限范围')), + ('perms', models.ManyToManyField(blank=True, to='system.Permission', verbose_name='功能权限')), + ], + options={ + 'verbose_name': '角色', + 'verbose_name_plural': '角色', + }, + ), + migrations.CreateModel( + name='HistoricalDict', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(blank=True, editable=False, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(max_length=60, verbose_name='名称')), + ('code', models.CharField(blank=True, max_length=30, null=True, verbose_name='编号')), + ('description', models.TextField(blank=True, null=True, verbose_name='描述')), + ('sort', models.IntegerField(default=1, verbose_name='排序')), + ('is_used', models.BooleanField(default=True, verbose_name='是否有效')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('parent', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='system.Dict', verbose_name='父')), + ('type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='system.DictType', verbose_name='类型')), + ], + options={ + 'verbose_name': 'historical 字典', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='File', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')), + ('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')), + ('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='名称')), + ('size', models.IntegerField(blank=True, default=1, null=True, verbose_name='文件大小')), + ('file', models.FileField(upload_to='%Y/%m/%d/', verbose_name='文件')), + ('mime', models.CharField(blank=True, max_length=120, null=True, verbose_name='文件格式')), + ('type', models.CharField(choices=[('文档', '文档'), ('视频', '视频'), ('音频', '音频'), ('图片', '图片'), ('其它', '其它')], default='文档', max_length=50, verbose_name='文件类型')), + ('path', models.CharField(blank=True, max_length=200, null=True, verbose_name='地址')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='file_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='file_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'verbose_name': '文件库', + 'verbose_name_plural': '文件库', + }, + ), + migrations.AddField( + model_name='dict', + name='type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='system.DictType', verbose_name='类型'), + ), + migrations.AddField( + model_name='user', + name='dept', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='system.Organization', verbose_name='组织'), + ), + migrations.AddField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'), + ), + migrations.AddField( + model_name='user', + name='position', + field=models.ManyToManyField(blank=True, to='system.Position', verbose_name='岗位'), + ), + migrations.AddField( + model_name='user', + name='roles', + field=models.ManyToManyField(blank=True, to='system.Role', verbose_name='角色'), + ), + migrations.AddField( + model_name='user', + name='superior', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='上级主管'), + ), + migrations.AddField( + model_name='user', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), + ), + migrations.AlterUniqueTogether( + name='dict', + unique_together={('name', 'is_used', 'type')}, + ), + ] diff --git a/hb_server/apps/system/migrations/0002_auto_20210718_0918.py b/hb_server/apps/system/migrations/0002_auto_20210718_0918.py new file mode 100644 index 0000000..eaaaa6c --- /dev/null +++ b/hb_server/apps/system/migrations/0002_auto_20210718_0918.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.7 on 2021-07-18 01:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='permission', + name='method', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='方法/代号'), + ), + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + ] diff --git a/hb_server/apps/system/migrations/__init__.py b/hb_server/apps/system/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hb_server/apps/system/mixins.py b/hb_server/apps/system/mixins.py new file mode 100644 index 0000000..b1a70c0 --- /dev/null +++ b/hb_server/apps/system/mixins.py @@ -0,0 +1,48 @@ +from django.db.models.query import QuerySet + +class CreateUpdateModelAMixin: + """ + 业务用基本表A用 + """ + def perform_create(self, serializer): + serializer.save(create_by = self.request.user) + + def perform_update(self, serializer): + serializer.save(update_by = self.request.user) + +class CreateUpdateModelBMixin: + """ + 业务用基本表B用 + """ + def perform_create(self, serializer): + serializer.save(create_by = self.request.user, belong_dept=self.request.user.dept) + + def perform_update(self, serializer): + serializer.save(update_by = self.request.user) + +class CreateUpdateCustomMixin: + """ + 整合 + """ + def perform_create(self, serializer): + if hasattr(self.queryset.model, 'belong_dept'): + serializer.save(create_by = self.request.user, belong_dept=self.request.user.dept) + else: + serializer.save(create_by = self.request.user) + def perform_update(self, serializer): + serializer.save(update_by = self.request.user) + +class OptimizationMixin: + """ + 性能优化,需要在序列化器里定义setup_eager_loading,可在必要的View下继承 + """ + def get_queryset(self): + queryset = self.queryset + if isinstance(queryset, QuerySet): + # Ensure queryset is re-evaluated on each request. + queryset = queryset.all() + if hasattr(self.get_serializer_class(), 'setup_eager_loading'): + queryset = self.get_serializer_class().setup_eager_loading(queryset) # 性能优化 + return queryset + + \ No newline at end of file diff --git a/hb_server/apps/system/models.py b/hb_server/apps/system/models.py new file mode 100644 index 0000000..7515bce --- /dev/null +++ b/hb_server/apps/system/models.py @@ -0,0 +1,218 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.db.models.base import Model +import django.utils.timezone as timezone +from django.db.models.query import QuerySet + +from utils.model import SoftModel, BaseModel +from simple_history.models import HistoricalRecords + + + +class Position(BaseModel): + """ + 职位/岗位 + """ + name = models.CharField('名称', max_length=32, unique=True) + description = models.CharField('描述', max_length=50, blank=True, null=True) + + class Meta: + verbose_name = '职位/岗位' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class Permission(SoftModel): + """ + 功能权限:目录,菜单,接口 + """ + menu_type_choices = ( + ('目录', '目录'), + ('菜单', '菜单'), + ('接口', '接口') + ) + name = models.CharField('名称', max_length=30) + type = models.CharField('类型', max_length=20, + choices=menu_type_choices, default='接口') + is_frame = models.BooleanField('外部链接', default=False) + sort = models.IntegerField('排序标记', default=1) + parent = models.ForeignKey('self', null=True, blank=True, + on_delete=models.SET_NULL, verbose_name='父') + method = models.CharField('方法/代号', max_length=50, null=True, blank=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = '功能权限表' + verbose_name_plural = verbose_name + ordering = ['sort'] + + +class Organization(SoftModel): + """ + 组织架构 + """ + organization_type_choices = ( + ('公司', '公司'), + ('部门', '部门') + ) + name = models.CharField('名称', max_length=60) + type = models.CharField('类型', max_length=20, + choices=organization_type_choices, default='部门') + parent = models.ForeignKey('self', null=True, blank=True, + on_delete=models.SET_NULL, verbose_name='父') + + class Meta: + verbose_name = '组织架构' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class Role(SoftModel): + """ + 角色 + """ + data_type_choices = ( + ('全部', '全部'), + ('自定义', '自定义'), + ('同级及以下', '同级及以下'), + ('本级及以下', '本级及以下'), + ('本级', '本级'), + ('仅本人', '仅本人') + ) + name = models.CharField('角色', max_length=32, unique=True) + perms = models.ManyToManyField(Permission, blank=True, verbose_name='功能权限') + datas = models.CharField('数据权限', max_length=50, + choices=data_type_choices, default='本级及以下') + depts = models.ManyToManyField( + Organization, blank=True, verbose_name='权限范围') + description = models.CharField('描述', max_length=50, blank=True, null=True) + + class Meta: + verbose_name = '角色' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class User(AbstractUser): + """ + 用户 + """ + name = models.CharField('姓名', max_length=20, null=True, blank=True) + phone = models.CharField('手机号', max_length=11, + null=True, blank=True, unique=True) + avatar = models.CharField( + '头像', default='/media/default/avatar.png', max_length=100, null=True, blank=True) + dept = models.ForeignKey( + Organization, null=True, blank=True, on_delete=models.SET_NULL, verbose_name='组织') + position = models.ManyToManyField(Position, blank=True, verbose_name='岗位') + superior = models.ForeignKey( + 'self', null=True, blank=True, on_delete=models.SET_NULL, verbose_name='上级主管') + roles = models.ManyToManyField(Role, blank=True, verbose_name='角色') + + class Meta: + verbose_name = '用户信息' + verbose_name_plural = verbose_name + ordering = ['id'] + + def __str__(self): + return self.username + +class DictType(SoftModel): + """ + 数据字典类型 + """ + name = models.CharField('名称', max_length=30) + code = models.CharField('代号', unique=True, max_length=30) + parent = models.ForeignKey('self', null=True, blank=True, + on_delete=models.SET_NULL, verbose_name='父') + + class Meta: + verbose_name = '字典类型' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + + +class Dict(SoftModel): + """ + 数据字典 + """ + name = models.CharField('名称', max_length=60) + code = models.CharField('编号', max_length=30, null=True, blank=True) + description = models.TextField('描述', blank=True, null=True) + type = models.ForeignKey( + DictType, on_delete=models.CASCADE, verbose_name='类型') + sort = models.IntegerField('排序', default=1) + parent = models.ForeignKey('self', null=True, blank=True, + on_delete=models.SET_NULL, verbose_name='父') + is_used = models.BooleanField('是否有效', default=True) + history = HistoricalRecords() + + class Meta: + verbose_name = '字典' + verbose_name_plural = verbose_name + unique_together = ('name', 'is_used', 'type') + + def __str__(self): + return self.name + +class CommonAModel(SoftModel): + """ + 业务用基本表A,包含create_by, update_by字段 + """ + create_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, verbose_name='创建人', related_name= '%(class)s_create_by') + update_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, verbose_name='最后编辑人', related_name= '%(class)s_update_by') + + class Meta: + abstract = True + +class CommonBModel(SoftModel): + """ + 业务用基本表B,包含create_by, update_by, belong_dept字段 + """ + create_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, verbose_name='创建人', related_name = '%(class)s_create_by') + update_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, verbose_name='最后编辑人', related_name = '%(class)s_update_by') + belong_dept = models.ForeignKey( + Organization, null=True, blank=True, on_delete=models.SET_NULL, verbose_name='所属部门', related_name= '%(class)s_belong_dept') + + class Meta: + abstract = True + + +class File(CommonAModel): + """ + 文件存储表,业务表根据具体情况选择是否外键关联 + """ + name = models.CharField('名称', max_length=100, null=True, blank=True) + size = models.IntegerField('文件大小', default=1, null=True, blank=True) + file = models.FileField('文件', upload_to='%Y/%m/%d/') + type_choices = ( + ('文档', '文档'), + ('视频', '视频'), + ('音频', '音频'), + ('图片', '图片'), + ('其它', '其它') + ) + mime = models.CharField('文件格式', max_length=120, null=True, blank=True) + type = models.CharField('文件类型', max_length=50, choices=type_choices, default='文档') + path = models.CharField('地址', max_length=200, null=True, blank=True) + + class Meta: + verbose_name = '文件库' + verbose_name_plural = verbose_name + + def __str__(self): + return self.name \ No newline at end of file diff --git a/hb_server/apps/system/permission.py b/hb_server/apps/system/permission.py new file mode 100644 index 0000000..37f85ec --- /dev/null +++ b/hb_server/apps/system/permission.py @@ -0,0 +1,97 @@ +from django.core.cache import cache +from rest_framework.permissions import BasePermission +from utils.queryset import get_child_queryset2 +from .models import Permission +from django.db.models import Q + +def get_permission_list(user): + """ + 获取权限列表,可用redis存取 + """ + if user.is_superuser: + perms_list = ['admin'] + else: + perms = Permission.objects.none() + roles = user.roles.all() + if roles: + for i in roles: + perms = perms | i.perms.all() + perms_list = perms.values_list('method', flat=True) + perms_list = list(set(perms_list)) + cache.set(user.username + '__perms', perms_list, 60*60) + return perms_list + + +class RbacPermission(BasePermission): + """ + 基于角色的权限校验类 + """ + + def has_permission(self, request, view): + """ + 权限校验逻辑 + :param request: + :param view: + :return: + """ + if not request.user: + perms = ['visitor'] # 如果没有经过认证,视为游客 + else: + perms = cache.get(request.user.username + '__perms') + if not perms: + perms = get_permission_list(request.user) + if perms: + if 'admin' in perms: + return True + elif not hasattr(view, 'perms_map'): + return True + else: + perms_map = view.perms_map + _method = request._request.method.lower() + if perms_map: + for key in perms_map: + if key == _method or key == '*': + if perms_map[key] in perms or perms_map[key] == '*': + return True + return False + else: + return False + + def has_object_permission(self, request, view, obj): + """ + Return `True` if permission is granted, `False` otherwise. + """ + # if not request.user: + # return False + # if hasattr(obj, 'belong_dept'): + # has_obj_perm(request.user, obj) + return True + +def has_obj_perm(user, obj): + """ + 数据权限控权 + 返回对象的是否可以操作 + 需要控数据权限的表需有belong_dept, create_by, update_by字段(部门, 创建人, 编辑人) + 传入user, obj实例 + """ + roles = user.roles + data_range = roles.values_list('datas', flat=True) + if '全部' in data_range: + return True + elif '自定义' in data_range: + if roles.depts.exists(): + if obj.belong_dept not in roles.depts: + return False + elif '同级及以下' in data_range: + if user.dept.parent: + belong_depts = get_child_queryset2(user.dept.parent) + if obj.belong_dept not in belong_depts: + return False + elif '本级及以下' in data_range: + belong_depts = get_child_queryset2(user.dept) + if obj.belong_dept not in belong_depts: + return False + elif '本级' in data_range: + if obj.belong_dept is not user.dept: + return False + return True \ No newline at end of file diff --git a/hb_server/apps/system/permission_data.py b/hb_server/apps/system/permission_data.py new file mode 100644 index 0000000..6e9ad71 --- /dev/null +++ b/hb_server/apps/system/permission_data.py @@ -0,0 +1,98 @@ +from django.db.models import Q +from django.db.models.query import QuerySet +from rest_framework.generics import GenericAPIView +from apps.system.mixins import CreateUpdateModelBMixin +from utils.queryset import get_child_queryset2 + + +class RbacFilterSet(CreateUpdateModelBMixin, object): + """ + 数据权限控权返回的queryset + 在必须的View下继承 + 需要控数据权限的表需有belong_dept, create_by, update_by字段(部门, 创建人, 编辑人) + 带性能优化 + 包括必要的创建和编辑操作 + + 此处对性能有较大影响,根据业务需求进行修改或取舍 + """ + def get_queryset(self): + assert self.queryset is not None, ( + "'%s' should either include a `queryset` attribute, " + "or override the `get_queryset()` method." + % self.__class__.__name__ + ) + + queryset = self.queryset + if isinstance(queryset, QuerySet): + # Ensure queryset is re-evaluated on each request. + queryset = queryset.all() + + if hasattr(self.get_serializer_class(), 'setup_eager_loading'): + queryset = self.get_serializer_class().setup_eager_loading(queryset) # 性能优化 + + if self.request.user.is_superuser: + return queryset + + if hasattr(queryset.model, 'belong_dept'): + user = self.request.user + roles = user.roles + data_range = roles.values_list('datas', flat=True) + if '全部' in data_range: + return queryset + elif '自定义' in data_range: + if roles.depts.exists(): + queryset = queryset.filter(belong_dept__in = roles.depts) + return queryset + elif '同级及以下' in data_range: + if user.dept.parent: + belong_depts = get_child_queryset2(user.dept.parent) + queryset = queryset.filter(belong_dept__in = belong_depts) + return queryset + elif '本级及以下' in data_range: + belong_depts = get_child_queryset2(user.dept) + queryset = queryset.filter(belong_dept__in = belong_depts) + return queryset + elif '本级' in data_range: + queryset = queryset.filter(belong_dept = user.dept) + return queryset + elif '仅本人' in data_range: + queryset = queryset.filter(Q(create_by=user)|Q(update_by=user)) + return queryset + return queryset + + +def rbac_filter_queryset(user, queryset): + """ + 数据权限控权返回的queryset方法 + 需要控数据权限的表需有belong_dept, create_by, update_by字段(部门, 创建人, 编辑人) + 传入user实例,queryset + """ + if user.is_superuser: + return queryset + + roles = user.roles + data_range = roles.values_list('datas', flat=True) + if hasattr(queryset.model, 'belong_dept'): + if '全部' in data_range: + return queryset + elif '自定义' in data_range: + if roles.depts.exists(): + queryset = queryset.filter(belong_dept__in = roles.depts) + return queryset + elif '同级及以下' in data_range: + if user.dept.parent: + belong_depts = get_child_queryset2(user.dept.parent) + queryset = queryset.filter(belong_dept__in = belong_depts) + return queryset + elif '本级及以下' in data_range: + belong_depts = get_child_queryset2(user.dept) + queryset = queryset.filter(belong_dept__in = belong_depts) + return queryset + elif '本级' in data_range: + queryset = queryset.filter(belong_dept = user.dept) + return queryset + elif '仅本人' in data_range: + queryset = queryset.filter(Q(create_by=user)|Q(update_by=user)) + return queryset + return queryset + diff --git a/hb_server/apps/system/serializers.py b/hb_server/apps/system/serializers.py new file mode 100644 index 0000000..90f2d00 --- /dev/null +++ b/hb_server/apps/system/serializers.py @@ -0,0 +1,173 @@ +import re + +from django_celery_beat.models import PeriodicTask, CrontabSchedule, IntervalSchedule +from rest_framework import serializers + +from .models import (Dict, DictType, File, Organization, Permission, Position, + Role, User) + +class IntervalSerializer(serializers.ModelSerializer): + class Meta: + model = IntervalSchedule + fields = '__all__' + +class CrontabSerializer(serializers.ModelSerializer): + class Meta: + model = CrontabSchedule + exclude = ['timezone'] + +class PTaskCreateUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = PeriodicTask + fields = ['name', 'task', 'interval', 'crontab', 'args', 'kwargs'] + +class PTaskSerializer(serializers.ModelSerializer): + interval_ = IntervalSerializer(source='interval', read_only=True) + crontab_ = CrontabSerializer(source='crontab', read_only=True) + schedule = serializers.SerializerMethodField() + timetype = serializers.SerializerMethodField() + class Meta: + model = PeriodicTask + fields = '__all__' + @staticmethod + def setup_eager_loading(queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.select_related('interval','crontab') + return queryset + + def get_schedule(self, obj): + if obj.interval: + return obj.interval.__str__() + if obj.crontab: + return obj.crontab.__str__() + return '' + + def get_timetype(self, obj): + if obj.interval: + return 'interval' + if obj.crontab: + return 'crontab' + return 'interval' + +class FileSerializer(serializers.ModelSerializer): + class Meta: + model = File + fields = "__all__" + +class DictTypeSerializer(serializers.ModelSerializer): + """ + 数据字典类型序列化 + """ + class Meta: + model = DictType + fields = '__all__' + + +class DictSerializer(serializers.ModelSerializer): + """ + 数据字典序列化 + """ + class Meta: + model = Dict + fields = '__all__' + + +class PositionSerializer(serializers.ModelSerializer): + """ + 岗位序列化 + """ + class Meta: + model = Position + fields = '__all__' + + +class RoleSerializer(serializers.ModelSerializer): + """ + 角色序列化 + """ + class Meta: + model = Role + fields = '__all__' + + +class PermissionSerializer(serializers.ModelSerializer): + """ + 权限序列化 + """ + class Meta: + model = Permission + fields = '__all__' + + +class OrganizationSerializer(serializers.ModelSerializer): + """ + 组织架构序列化 + """ + type = serializers.ChoiceField( + choices=Organization.organization_type_choices, default='部门') + + class Meta: + model = Organization + fields = '__all__' + + +class UserListSerializer(serializers.ModelSerializer): + """ + 用户列表序列化 + """ + dept_name = serializers.StringRelatedField(source='dept') + roles_name = serializers.StringRelatedField(source='roles', many=True) + class Meta: + model = User + fields = ['id', 'name', 'phone', 'email', 'position', + 'username', 'is_active', 'date_joined', 'dept_name', 'dept', 'roles', 'avatar', 'roles_name'] + + @staticmethod + def setup_eager_loading(queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.select_related('superior','dept') + queryset = queryset.prefetch_related('roles',) + return queryset + +class UserModifySerializer(serializers.ModelSerializer): + """ + 用户编辑序列化 + """ + phone = serializers.CharField(max_length=11, read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'name', 'phone', 'email', 'dept', + 'position', 'avatar', 'is_active', 'roles', 'is_superuser'] + + def validate_phone(self, phone): + re_phone = '^1[358]\d{9}$|^147\d{8}$|^176\d{8}$' + if not re.match(re_phone, phone): + raise serializers.ValidationError('手机号码不合法') + return phone + + +class UserCreateSerializer(serializers.ModelSerializer): + """ + 创建用户序列化 + """ + username = serializers.CharField(required=True) + phone = serializers.CharField(max_length=11, read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'name', 'phone', 'email', 'dept', + 'position', 'avatar', 'is_active', 'roles'] + + def validate_username(self, username): + if User.objects.filter(username=username): + raise serializers.ValidationError(username + ' 账号已存在') + return username + + def validate_phone(self, phone): + re_phone = '^1[358]\d{9}$|^147\d{8}$|^176\d{8}$' + if not re.match(re_phone, phone): + raise serializers.ValidationError('手机号码不合法') + if User.objects.filter(phone=phone): + raise serializers.ValidationError('手机号已经被注册') + return phone diff --git a/hb_server/apps/system/signals.py b/hb_server/apps/system/signals.py new file mode 100644 index 0000000..334bc57 --- /dev/null +++ b/hb_server/apps/system/signals.py @@ -0,0 +1,12 @@ +from django.db.models.signals import m2m_changed +from .models import Role, Permission, User +from django.dispatch import receiver +from django.core.cache import cache +from .permission import get_permission_list + +# 变更用户角色时动态更新权限或者前端刷新 +@receiver(m2m_changed, sender=User.roles.through) +def update_perms_cache_user(sender, instance, action, **kwargs): + if action in ['post_remove', 'post_add']: + if cache.get(instance.username+'__perms', None): + get_permission_list(instance) \ No newline at end of file diff --git a/hb_server/apps/system/tasks.py b/hb_server/apps/system/tasks.py new file mode 100644 index 0000000..6d2adae --- /dev/null +++ b/hb_server/apps/system/tasks.py @@ -0,0 +1,9 @@ +# Create your tasks here +from __future__ import absolute_import, unicode_literals + +from celery import shared_task + + +@shared_task +def show(): + print('ok') \ No newline at end of file diff --git a/hb_server/apps/system/tests.py b/hb_server/apps/system/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/hb_server/apps/system/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/hb_server/apps/system/urls.py b/hb_server/apps/system/urls.py new file mode 100644 index 0000000..02a4ecb --- /dev/null +++ b/hb_server/apps/system/urls.py @@ -0,0 +1,19 @@ +from django.urls import path, include +from .views import TaskList, UserViewSet, OrganizationViewSet, PermissionViewSet, RoleViewSet, PositionViewSet, TestView, DictTypeViewSet, DictViewSet, PTaskViewSet +from rest_framework import routers + + +router = routers.DefaultRouter() +router.register('user', UserViewSet, basename="user") +router.register('organization', OrganizationViewSet, basename="organization") +router.register('permission', PermissionViewSet, basename="permission") +router.register('role', RoleViewSet, basename="role") +router.register('position', PositionViewSet, basename="position") +router.register('dicttype', DictTypeViewSet, basename="dicttype") +router.register('dict', DictViewSet, basename="dict") +router.register('ptask', PTaskViewSet, basename="ptask") +urlpatterns = [ + path('', include(router.urls)), + path('task/', TaskList.as_view()), + path('test/', TestView.as_view()) +] diff --git a/hb_server/apps/system/views.py b/hb_server/apps/system/views.py new file mode 100644 index 0000000..3b3dd6f --- /dev/null +++ b/hb_server/apps/system/views.py @@ -0,0 +1,351 @@ +import logging + +from django.conf import settings +from django.contrib.auth.hashers import check_password, make_password +from django.core.cache import cache +from django_celery_beat.models import PeriodicTask, IntervalSchedule, CrontabSchedule +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import serializers, status +from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, + ListModelMixin, RetrieveModelMixin, + UpdateModelMixin) +from rest_framework.pagination import PageNumberPagination +from rest_framework.parsers import (FileUploadParser, JSONParser, + MultiPartParser) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework.exceptions import ValidationError, ParseError +from utils.queryset import get_child_queryset2 + +from .filters import UserFilter +from .mixins import CreateUpdateModelAMixin, OptimizationMixin +from .models import (Dict, DictType, File, Organization, Permission, Position, + Role, User) +from .permission import RbacPermission, get_permission_list +from .permission_data import RbacFilterSet +from .serializers import (DictSerializer, DictTypeSerializer, FileSerializer, + OrganizationSerializer, PermissionSerializer, + PositionSerializer, RoleSerializer, PTaskSerializer,PTaskCreateUpdateSerializer, + UserCreateSerializer, UserListSerializer, + UserModifySerializer) + +logger = logging.getLogger('log') +# logger.info('请求成功! response_code:{};response_headers:{};response_body:{}'.format(response_code, response_headers, response_body[:251])) +# logger.error('请求出错-{}'.format(error)) + +from server.celery import app as celery_app +class TaskList(APIView): + permission_classes = () + + def get(self, requests): + tasks = list(sorted(name for name in celery_app.tasks if not name.startswith('celery.'))) + return Response(tasks) + +class LogoutView(APIView): + permission_classes = [] + + def get(self, request, *args, **kwargs): # 可将token加入黑名单 + return Response(status=status.HTTP_200_OK) + +class PTaskViewSet(OptimizationMixin, ModelViewSet): + perms_map = {'get': '*', 'post': 'ptask_create', + 'put': 'ptask_update', 'delete': 'ptask_delete'} + queryset = PeriodicTask.objects.exclude(name__contains='celery.') + serializer_class = PTaskSerializer + search_fields = ['name'] + filterset_fields = ['enabled'] + ordering = ['-pk'] + + @action(methods=['put'], detail=True, perms_map={'put':'task_update'}, + url_name='task_toggle') + def toggle(self, request, pk=None): + """ + 修改启用禁用状态 + """ + obj = self.get_object() + obj.enabled = False if obj.enabled else True + obj.save() + return Response(status=status.HTTP_200_OK) + + def get_serializer_class(self): + if self.action in ['list', 'retrieve']: + return PTaskSerializer + return PTaskCreateUpdateSerializer + + def create(self, request, *args, **kwargs): + data = request.data + timetype = data.get('timetype', None) + interval_ = data.get('interval_', None) + crontab_ = data.get('crontab_', None) + if timetype == 'interval' and interval_: + data['crontab'] = None + try: + interval, _ = IntervalSchedule.objects.get_or_create(**interval_, defaults = interval_) + data['interval'] = interval.id + except: + raise ValidationError('时间策略有误') + if timetype == 'crontab' and crontab_: + data['interval'] = None + try: + crontab_['timezone'] = 'Asia/Shanghai' + crontab, _ = CrontabSchedule.objects.get_or_create(**crontab_, defaults = crontab_) + data['crontab'] = crontab.id + except: + raise ValidationError('时间策略有误') + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(status=status.HTTP_200_OK) + + def update(self, request, *args, **kwargs): + data = request.data + timetype = data.get('timetype', None) + interval_ = data.get('interval_', None) + crontab_ = data.get('crontab_', None) + if timetype == 'interval' and interval_: + data['crontab'] = None + try: + if 'id' in interval_: + del interval_['id'] + interval, _ = IntervalSchedule.objects.get_or_create(**interval_, defaults = interval_) + data['interval'] = interval.id + except: + raise ValidationError('时间策略有误') + if timetype == 'crontab' and crontab_: + data['interval'] = None + try: + crontab_['timezone'] = 'Asia/Shanghai' + if 'id'in crontab_: + del crontab_['id'] + crontab, _ = CrontabSchedule.objects.get_or_create(**crontab_, defaults = crontab_) + data['crontab'] = crontab.id + except: + raise ValidationError('时间策略有误') + instance = self.get_object() + serializer = self.get_serializer(instance, data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(status=status.HTTP_200_OK) + + +class DictTypeViewSet(ModelViewSet): + """ + 数据字典类型-增删改查 + """ + perms_map = {'get': '*', 'post': 'dicttype_create', + 'put': 'dicttype_update', 'delete': 'dicttype_delete'} + queryset = DictType.objects.all() + serializer_class = DictTypeSerializer + pagination_class = None + search_fields = ['name'] + ordering_fields = ['pk'] + ordering = ['pk'] + + +class DictViewSet(ModelViewSet): + """ + 数据字典-增删改查 + """ + perms_map = {'get': '*', 'post': 'dict_create', + 'put': 'dict_update', 'delete': 'dict_delete'} + # queryset = Dict.objects.get_queryset(all=True) # 获取全部的,包括软删除的 + queryset = Dict.objects.all() + filterset_fields = ['type', 'is_used', 'type__code'] + serializer_class = DictSerializer + search_fields = ['name'] + ordering_fields = ['sort'] + ordering = ['sort'] + + def paginate_queryset(self, queryset): + """ + 如果查询参数里没有page但有type或type__code时则不分页,否则请求分页 + 也可用utils.pageornot方法 + """ + if self.paginator is None: + return None + elif (not self.request.query_params.get('page', None)) and ((self.request.query_params.get('type__code', None)) or (self.request.query_params.get('type', None))): + return None + return self.paginator.paginate_queryset(queryset, self.request, view=self) + +class PositionViewSet(ModelViewSet): + """ + 岗位-增删改查 + """ + perms_map = {'get': '*', 'post': 'position_create', + 'put': 'position_update', 'delete': 'position_delete'} + queryset = Position.objects.all() + serializer_class = PositionSerializer + pagination_class = None + search_fields = ['name','description'] + ordering_fields = ['pk'] + ordering = ['pk'] + + +class TestView(APIView): + swagger_schema = None + perms_map = {'get': 'test_view'} # 单个API控权 + authentication_classes = [] + permission_classes = [] + def get(self, request, format=None): + return Response('测试api接口') + + +class PermissionViewSet(ModelViewSet): + """ + 权限-增删改查 + """ + perms_map = {'get': '*', 'post': 'perm_create', + 'put': 'perm_update', 'delete': 'perm_delete'} + queryset = Permission.objects.all() + serializer_class = PermissionSerializer + pagination_class = None + search_fields = ['name'] + ordering_fields = ['sort'] + ordering = ['pk'] + + +class OrganizationViewSet(ModelViewSet): + """ + 组织机构-增删改查 + """ + perms_map = {'get': '*', 'post': 'org_create', + 'put': 'org_update', 'delete': 'org_delete'} + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + pagination_class = None + search_fields = ['name', 'type'] + ordering_fields = ['pk'] + ordering = ['pk'] + + +class RoleViewSet(ModelViewSet): + """ + 角色-增删改查 + """ + perms_map = {'get': '*', 'post': 'role_create', + 'put': 'role_update', 'delete': 'role_delete'} + queryset = Role.objects.all() + serializer_class = RoleSerializer + pagination_class = None + search_fields = ['name'] + ordering_fields = ['pk'] + ordering = ['pk'] + + +class UserViewSet(ModelViewSet): + """ + 用户管理-增删改查 + list: + 查询用户列表 + """ + perms_map = {'get': '*', 'post': 'user_create', + 'put': 'user_update', 'delete': 'user_delete'} + queryset = User.objects.all() + serializer_class = UserListSerializer + filterset_class = UserFilter + search_fields = ['username', 'name', 'phone', 'email'] + ordering_fields = ['-pk'] + + def get_queryset(self): + queryset = self.queryset + if hasattr(self.get_serializer_class(), 'setup_eager_loading'): + queryset = self.get_serializer_class().setup_eager_loading(queryset) # 性能优化 + dept = self.request.query_params.get('dept', None) # 该部门及其子部门所有员工 + if dept: + deptqueryset = get_child_queryset2(Organization.objects.get(pk=dept)) + queryset = queryset.filter(dept__in=deptqueryset) + return queryset + + def get_serializer_class(self): + # 根据请求类型动态变更serializer + if self.action == 'create': + return UserCreateSerializer + elif self.action == 'list': + return UserListSerializer + return UserModifySerializer + + def create(self, request, *args, **kwargs): + # 创建用户默认添加密码 + password = request.data['password'] if 'password' in request.data else None + if password: + password = make_password(password) + else: + password = make_password('0000') + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(password=password) + return Response(serializer.data) + + @action(methods=['put'], detail=False, permission_classes=[IsAuthenticated], # perms_map={'put':'change_password'} + url_name='change_password') + def password(self, request, pk=None): + """ + 修改密码 + """ + user = request.user + old_password = request.data['old_password'] + if check_password(old_password, user.password): + new_password1 = request.data['new_password1'] + new_password2 = request.data['new_password2'] + if new_password1 == new_password2: + user.set_password(new_password2) + user.save() + return Response('密码修改成功!', status=status.HTTP_200_OK) + else: + return Response('新密码两次输入不一致!', status=status.HTTP_400_BAD_REQUEST) + else: + return Response('旧密码错误!', status=status.HTTP_400_BAD_REQUEST) + + # perms_map={'get':'*'}, 自定义action控权 + @action(methods=['get'], detail=False, url_name='my_info', permission_classes=[IsAuthenticated]) + def info(self, request, pk=None): + """ + 初始化用户信息 + """ + user = request.user + perms = get_permission_list(user) + data = { + 'id': user.id, + 'username': user.username, + 'name': user.name, + 'roles': user.roles.values_list('name', flat=True), + 'avatar': user.avatar, + 'perms': perms, + } + return Response(data) + +class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet): + """ + 文件上传用 + """ + perms_map = None + permission_classes=[IsAuthenticated] + parser_classes = [MultiPartParser, JSONParser] + queryset = File.objects.all() + serializer_class = FileSerializer + filterset_fields = ['type'] + search_fields = ['name'] + ordering = ['-create_time'] + + def perform_create(self, serializer): + fileobj = self.request.data.get('file') + name = fileobj._name + size = fileobj.size + mime = fileobj.content_type + type = '其它' + if 'image' in mime: + type = '图片' + elif 'video' in mime: + type = '视频' + elif 'audio' in mime: + type = '音频' + elif 'application' or 'text' in mime: + type = '文档' + instance = serializer.save(create_by = self.request.user, name=name, size=size, type=type, mime=mime) + instance.path = settings.MEDIA_URL + instance.file.name + instance.save() diff --git a/hb_server/db.json b/hb_server/db.json new file mode 100644 index 0000000..e75d03d --- /dev/null +++ b/hb_server/db.json @@ -0,0 +1,498 @@ +[ + { + "model": "system.permission", + "pk": 1, + "fields": { + "create_time": "2020-05-14T10:03:00Z", + "update_time": "2020-05-16T15:28:13.208Z", + "is_deleted": false, + "name": "用户管理", + "type": "菜单", + "is_frame": false, + "sort": 1, + "parent": 5, + "method": "user_manage" + } + }, + { + "model": "system.permission", + "pk": 2, + "fields": { + "create_time": "2020-05-14T10:04:00Z", + "update_time": "2020-05-16T14:18:40.148Z", + "is_deleted": false, + "name": "新增用户", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 1, + "method": "user_create" + } + }, + { + "model": "system.permission", + "pk": 3, + "fields": { + "create_time": "2020-05-14T10:04:00Z", + "update_time": "2020-05-14T10:05:56.206Z", + "is_deleted": false, + "name": "编辑用户", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 1, + "method": "user_update" + } + }, + { + "model": "system.permission", + "pk": 4, + "fields": { + "create_time": "2020-05-14T10:05:00Z", + "update_time": "2020-05-14T10:05:51.157Z", + "is_deleted": false, + "name": "删除用户", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 1, + "method": "user_delete" + } + }, + { + "model": "system.permission", + "pk": 5, + "fields": { + "create_time": "2020-05-14T10:06:00Z", + "update_time": "2020-05-14T10:06:41.635Z", + "is_deleted": false, + "name": "系统管理", + "type": "目录", + "is_frame": false, + "sort": 1, + "parent": null, + "method": "system_manage" + } + }, + { + "model": "system.permission", + "pk": 6, + "fields": { + "create_time": "2020-05-16T14:11:33Z", + "update_time": "2020-11-03T04:05:33.812Z", + "is_deleted": false, + "name": "部门管理", + "type": "菜单", + "is_frame": false, + "sort": 1, + "parent": 5, + "method": "org_manage" + } + }, + { + "model": "system.permission", + "pk": 8, + "fields": { + "create_time": "2020-05-16T14:20:28.582Z", + "update_time": "2020-05-16T14:20:28.582Z", + "is_deleted": false, + "name": "新增部门", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 6, + "method": "org_create" + } + }, + { + "model": "system.permission", + "pk": 9, + "fields": { + "create_time": "2020-05-16T14:20:48.772Z", + "update_time": "2020-05-16T14:20:48.773Z", + "is_deleted": false, + "name": "编辑部门", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 6, + "method": "org_update" + } + }, + { + "model": "system.permission", + "pk": 10, + "fields": { + "create_time": "2020-05-16T14:21:14.722Z", + "update_time": "2020-05-16T14:21:14.723Z", + "is_deleted": false, + "name": "删除部门", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 6, + "method": "org_delete" + } + }, + { + "model": "system.permission", + "pk": 11, + "fields": { + "create_time": "2020-05-16T14:21:43.163Z", + "update_time": "2020-05-16T14:21:43.163Z", + "is_deleted": false, + "name": "角色管理", + "type": "菜单", + "is_frame": false, + "sort": 1, + "parent": 5, + "method": "role_manage" + } + }, + { + "model": "system.permission", + "pk": 12, + "fields": { + "create_time": "2020-05-16T14:22:02.087Z", + "update_time": "2020-05-16T14:22:02.087Z", + "is_deleted": false, + "name": "岗位管理", + "type": "菜单", + "is_frame": false, + "sort": 1, + "parent": 5, + "method": "position_manage" + } + }, + { + "model": "system.permission", + "pk": 13, + "fields": { + "create_time": "2020-05-16T14:24:25.480Z", + "update_time": "2020-05-16T14:24:25.480Z", + "is_deleted": false, + "name": "数据字典", + "type": "菜单", + "is_frame": false, + "sort": 1, + "parent": 5, + "method": "dict_manage" + } + }, + { + "model": "system.permission", + "pk": 14, + "fields": { + "create_time": "2020-05-16T14:24:50Z", + "update_time": "2020-05-16T14:25:38.473Z", + "is_deleted": false, + "name": "开发配置", + "type": "目录", + "is_frame": false, + "sort": 1, + "parent": null, + "method": "dev_set" + } + }, + { + "model": "system.permission", + "pk": 15, + "fields": { + "create_time": "2020-05-16T14:25:17.244Z", + "update_time": "2020-05-16T14:25:17.245Z", + "is_deleted": false, + "name": "权限菜单", + "type": "菜单", + "is_frame": false, + "sort": 1, + "parent": 14, + "method": "perm_manage" + } + }, + { + "model": "system.permission", + "pk": 16, + "fields": { + "create_time": "2020-05-16T14:26:06.322Z", + "update_time": "2020-05-16T14:26:06.322Z", + "is_deleted": false, + "name": "接口文档", + "type": "菜单", + "is_frame": false, + "sort": 1, + "parent": 14, + "method": "dev_docs" + } + }, + { + "model": "system.permission", + "pk": 17, + "fields": { + "create_time": "2020-05-16T14:26:35.902Z", + "update_time": "2020-05-16T14:26:35.903Z", + "is_deleted": false, + "name": "新建权限", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 15, + "method": "perm_create" + } + }, + { + "model": "system.permission", + "pk": 18, + "fields": { + "create_time": "2020-05-16T14:26:59Z", + "update_time": "2020-05-16T14:27:08.114Z", + "is_deleted": false, + "name": "编辑权限", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 15, + "method": "perm_update" + } + }, + { + "model": "system.permission", + "pk": 19, + "fields": { + "create_time": "2020-05-16T14:27:29.245Z", + "update_time": "2020-05-16T14:27:29.245Z", + "is_deleted": false, + "name": "删除权限", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 15, + "method": "perm_delete" + } + }, + { + "model": "system.permission", + "pk": 20, + "fields": { + "create_time": "2020-05-16T14:28:49.606Z", + "update_time": "2020-05-16T14:28:49.606Z", + "is_deleted": false, + "name": "新建角色", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 11, + "method": "role_create" + } + }, + { + "model": "system.permission", + "pk": 21, + "fields": { + "create_time": "2020-05-16T14:29:25.424Z", + "update_time": "2020-05-16T14:29:25.424Z", + "is_deleted": false, + "name": "编辑角色", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 11, + "method": "role_update" + } + }, + { + "model": "system.permission", + "pk": 22, + "fields": { + "create_time": "2020-05-16T14:29:59.108Z", + "update_time": "2020-05-16T14:29:59.108Z", + "is_deleted": false, + "name": "删除角色", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 11, + "method": "role_delete" + } + }, + { + "model": "system.permission", + "pk": 23, + "fields": { + "create_time": "2020-05-16T14:31:28.635Z", + "update_time": "2020-05-16T14:31:28.635Z", + "is_deleted": false, + "name": "新建岗位", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 12, + "method": "position_create" + } + }, + { + "model": "system.permission", + "pk": 24, + "fields": { + "create_time": "2020-05-16T14:32:27.506Z", + "update_time": "2020-05-16T14:32:27.506Z", + "is_deleted": false, + "name": "编辑岗位", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 12, + "method": "position_update" + } + }, + { + "model": "system.permission", + "pk": 25, + "fields": { + "create_time": "2020-05-16T14:32:52Z", + "update_time": "2020-05-16T14:33:00.166Z", + "is_deleted": false, + "name": "删除岗位", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 12, + "method": "position_delete" + } + }, + { + "model": "system.permission", + "pk": 26, + "fields": { + "create_time": "2020-05-16T14:34:27.956Z", + "update_time": "2020-05-16T14:34:27.957Z", + "is_deleted": false, + "name": "新建字典类型", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 13, + "method": "dicttype_create" + } + }, + { + "model": "system.permission", + "pk": 27, + "fields": { + "create_time": "2020-05-16T14:34:50.126Z", + "update_time": "2020-05-16T14:34:50.127Z", + "is_deleted": false, + "name": "编辑字典类型", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 13, + "method": "dicttype_update" + } + }, + { + "model": "system.permission", + "pk": 28, + "fields": { + "create_time": "2020-05-16T14:35:06.146Z", + "update_time": "2020-05-16T14:35:06.147Z", + "is_deleted": false, + "name": "新建字典", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 13, + "method": "dict_create" + } + }, + { + "model": "system.permission", + "pk": 29, + "fields": { + "create_time": "2020-05-16T14:35:21.938Z", + "update_time": "2020-05-16T14:35:21.939Z", + "is_deleted": false, + "name": "编辑字典", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 13, + "method": "dict_update" + } + }, + { + "model": "system.permission", + "pk": 30, + "fields": { + "create_time": "2020-05-16T14:35:38.059Z", + "update_time": "2020-05-16T14:35:38.060Z", + "is_deleted": false, + "name": "删除字典", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 13, + "method": "dict_delete" + } + }, + { + "model": "system.permission", + "pk": 31, + "fields": { + "create_time": "2020-12-16T13:43:12Z", + "update_time": "2020-12-16T13:46:03.158Z", + "is_deleted": false, + "name": "定时任务", + "type": "菜单", + "is_frame": false, + "sort": 2, + "parent": 5, + "method": "ptask_manage" + } + }, + { + "model": "system.permission", + "pk": 32, + "fields": { + "create_time": "2020-12-16T13:43:37.247Z", + "update_time": "2020-12-16T13:43:37.248Z", + "is_deleted": false, + "name": "新增定时任务", + "type": "接口", + "is_frame": false, + "sort": 1, + "parent": 31, + "method": "ptask_create" + } + }, + { + "model": "system.permission", + "pk": 33, + "fields": { + "create_time": "2020-12-16T13:44:03.800Z", + "update_time": "2020-12-16T13:44:03.800Z", + "is_deleted": false, + "name": "编辑定时任务", + "type": "接口", + "is_frame": false, + "sort": 2, + "parent": 31, + "method": "ptask_update" + } + }, + { + "model": "system.permission", + "pk": 34, + "fields": { + "create_time": "2020-12-16T13:44:32.149Z", + "update_time": "2020-12-16T13:44:32.149Z", + "is_deleted": false, + "name": "删除定时任务", + "type": "接口", + "is_frame": false, + "sort": 3, + "parent": 31, + "method": "ptask_delete" + } + } + ] + \ No newline at end of file diff --git a/hb_server/log/.gitignore b/hb_server/log/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/hb_server/log/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/hb_server/manage.py b/hb_server/manage.py new file mode 100644 index 0000000..a2a7d24 --- /dev/null +++ b/hb_server/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings_dev') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/hb_server/media/default/avatar.png b/hb_server/media/default/avatar.png new file mode 100644 index 0000000..98d206e Binary files /dev/null and b/hb_server/media/default/avatar.png differ diff --git a/hb_server/requirements.txt b/hb_server/requirements.txt new file mode 100644 index 0000000..e7459d6 --- /dev/null +++ b/hb_server/requirements.txt @@ -0,0 +1,11 @@ +celery==5.1.2 +Django==3.2.6 +django-celery-beat==2.2.1 +django-cors-headers==3.7.0 +django-filter==2.4.0 +django-simple-history==3.0.0 +djangorestframework==3.12.4 +djangorestframework-simplejwt==4.7.2 +drf-yasg==1.20.0 +psycopg2==2.9.1 +psutil==5.8.0 diff --git a/hb_server/server/__init__.py b/hb_server/server/__init__.py new file mode 100644 index 0000000..1e3599b --- /dev/null +++ b/hb_server/server/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/hb_server/server/asgi.py b/hb_server/server/asgi.py new file mode 100644 index 0000000..2526a47 --- /dev/null +++ b/hb_server/server/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for server project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') + +application = get_asgi_application() diff --git a/hb_server/server/celery.py b/hb_server/server/celery.py new file mode 100644 index 0000000..818e248 --- /dev/null +++ b/hb_server/server/celery.py @@ -0,0 +1,22 @@ +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings_dev') + +app = Celery('server') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') \ No newline at end of file diff --git a/hb_server/server/settings.py b/hb_server/server/settings.py new file mode 100644 index 0000000..a7c7a02 --- /dev/null +++ b/hb_server/server/settings.py @@ -0,0 +1,277 @@ +""" +Django settings for server project. + +Generated by 'django-admin startproject' using Django 3.0.3. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +from datetime import datetime, timedelta +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'ez9z3a4m*$%srn9ve_t71yd!v+&xn9@0k(e(+l6#g1h=e5i4da' + +# SECURITY WARNING: don't run with debug turned on in production! + + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.admindocs', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'corsheaders', + 'django_celery_beat', + 'drf_yasg', + 'rest_framework', + "django_filters", + 'simple_history', + 'apps.system', + 'apps.monitor' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'simple_history.middleware.HistoryRequestMiddleware', +] + +ROOT_URLCONF = 'server.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': ['vuedist'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'server.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'static') +STATICFILES_DIRS = ( + os.path.join(BASE_DIR, 'vuedist/static'), +) + +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +# 默认主键 +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# restframework配置 +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + 'apps.system.permission.RbacPermission' + ], + 'DEFAULT_RENDERER_CLASSES': [ + 'utils.response.FitJSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer' + ], + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter' + ], + 'DEFAULT_PAGINATION_CLASS': 'utils.pagination.MyPagination', + 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S', + 'DATE_FORMAT': '%Y-%m-%d', + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + 'UNAUTHENTICATED_USER': None, + 'UNAUTHENTICATED_TOKEN': None, +} +# simplejwt配置 +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), +} + +# 跨域配置/可用nginx处理,无需引入corsheaders +CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_CREDENTIALS = False + +# Auth配置 +AUTH_USER_MODEL = 'system.User' +AUTHENTICATION_BACKENDS = ( + 'apps.system.authentication.CustomBackend', +) + +# 缓存配置,有需要可更改为redis +# CACHES = { +# "default": { +# "BACKEND": "django_redis.cache.RedisCache", +# "LOCATION": "redis://redis:6379/1", +# "OPTIONS": { +# "CLIENT_CLASS": "django_redis.client.DefaultClient", +# "PICKLE_VERSION": -1 +# } +# } +# } + +# celery配置,celery正常运行必须安装redis +CELERY_BROKER_URL = "redis://redis:6379/0" # 任务存储 +CELERYD_MAX_TASKS_PER_CHILD = 100 # 每个worker最多执行300个任务就会被销毁,可防止内存泄露 +CELERY_TIMEZONE = 'Asia/Shanghai' # 设置时区 +CELERY_ENABLE_UTC = True # 启动时区设置 + + +# 日志配置 +# 创建日志的路径 +LOG_PATH = os.path.join(BASE_DIR, 'log') +# 如果地址不存在,则自动创建log文件夹 +if not os.path.join(LOG_PATH): + os.mkdir(LOG_PATH) +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + # 日志格式 + 'standard': { + 'format': '[%(asctime)s] [%(filename)s:%(lineno)d] [%(module)s:%(funcName)s] ' + '[%(levelname)s]- %(message)s'}, + 'simple': { # 简单格式 + 'format': '%(levelname)s %(message)s' + }, + }, + # 过滤 + 'filters': { + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + }, + # 定义具体处理日志的方式 + 'handlers': { + # 默认记录所有日志 + 'default': { + 'level': 'INFO', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'all-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), + 'maxBytes': 1024 * 1024 * 5, # 文件大小 + 'backupCount': 5, # 备份数 + 'formatter': 'standard', # 输出格式 + 'encoding': 'utf-8', # 设置默认编码,否则打印出来汉字乱码 + }, + # 输出错误日志 + 'error': { + 'level': 'ERROR', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'error-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), + 'maxBytes': 1024 * 1024 * 5, # 文件大小 + 'backupCount': 5, # 备份数 + 'formatter': 'standard', # 输出格式 + 'encoding': 'utf-8', # 设置默认编码 + }, + # 控制台输出 + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'filters': ['require_debug_true'], + 'formatter': 'standard' + }, + # 输出info日志 + 'info': { + 'level': 'INFO', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_PATH, 'info-{}.log'.format(datetime.now().strftime('%Y-%m-%d'))), + 'maxBytes': 1024 * 1024 * 5, + 'backupCount': 5, + 'formatter': 'standard', + 'encoding': 'utf-8', # 设置默认编码 + }, + }, + # 配置用哪几种 handlers 来处理日志 + 'loggers': { + # 类型 为 django 处理所有类型的日志, 默认调用 + 'django': { + 'handlers': ['default', 'console'], + 'level': 'INFO', + 'propagate': False + }, + # log 调用时需要当作参数传入 + 'log': { + 'handlers': ['error', 'info', 'console', 'default'], + 'level': 'INFO', + 'propagate': True + }, + } +} diff --git a/hb_server/server/settings_dev.py b/hb_server/server/settings_dev.py new file mode 100644 index 0000000..a1d2664 --- /dev/null +++ b/hb_server/server/settings_dev.py @@ -0,0 +1,19 @@ +from .settings import * +DEBUG = True +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'hberp', + 'USER': 'postgres', + 'PASSWORD': 'zctest1234', + 'HOST': '47.95.0.242', + 'PORT': '5432', + } + # 'default': { + # 'ENGINE': 'django.db.backends.sqlite3', + # 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + # } +} + +# celery配置 +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' \ No newline at end of file diff --git a/hb_server/server/settings_pro.py b/hb_server/server/settings_pro.py new file mode 100644 index 0000000..a40e995 --- /dev/null +++ b/hb_server/server/settings_pro.py @@ -0,0 +1,15 @@ +from .settings import * +DEBUG = False +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'db', + 'USER': 'postgres', + 'PASSWORD': 'password', + 'HOST': 'localhost', + 'PORT': '5432', + } +} + +# celery配置 +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' \ No newline at end of file diff --git a/hb_server/server/urls.py b/hb_server/server/urls.py new file mode 100644 index 0000000..9580572 --- /dev/null +++ b/hb_server/server/urls.py @@ -0,0 +1,65 @@ +"""server URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from apps.system.views import FileViewSet, LogoutView +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import routers +from rest_framework.documentation import include_docs_urls +from rest_framework_simplejwt.views import (TokenObtainPairView, + TokenRefreshView) +from django.views.generic import TemplateView + +router = routers.DefaultRouter() +router.register('', FileViewSet, basename="file") + +schema_view = get_schema_view( + openapi.Info( + title="航玻ERP API", + default_version='v1', + contact=openapi.Contact(email="caoqianming@foxmail.com"), + license=openapi.License(name="MIT License"), + ), + public=True, + permission_classes=[], +) + +urlpatterns = [ + path('api/admin/doc/', include('django.contrib.admindocs.urls')), + path('api/admin/', admin.site.urls), + + # api文档 + path('api/docs/', include_docs_urls(title="接口文档", authentication_classes=[], permission_classes=[])), + path('api/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + + # api + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api/token/black/', LogoutView.as_view(), name='token_black'), + path('api/file/', include(router.urls)), + path('api/system/', include('apps.system.urls')), + path('api/monitor/', include('apps.monitor.urls')), + + # 前端页面入口 + path('',TemplateView.as_view(template_name="index.html")) +] + \ +static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + \ +static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + diff --git a/hb_server/server/wsgi.py b/hb_server/server/wsgi.py new file mode 100644 index 0000000..f369e60 --- /dev/null +++ b/hb_server/server/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for server project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings_dev') + +application = get_wsgi_application() diff --git a/hb_server/start.sh b/hb_server/start.sh new file mode 100644 index 0000000..b619690 --- /dev/null +++ b/hb_server/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash +if [ v"$DJANGO_ENV" == 'vdev' ]; then + python manage.py makemigrations system + python manage.py migrate + python manage.py runserver 0.0.0.0:80 + else + python manage.py migrate + python manage.py collectstatic --noinput + gunicorn server.wsgi:application -w 4 -k gthread -b 0.0.0.0:80 +fi diff --git a/hb_server/utils/__init__.py b/hb_server/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hb_server/utils/model.py b/hb_server/utils/model.py new file mode 100644 index 0000000..05d04ab --- /dev/null +++ b/hb_server/utils/model.py @@ -0,0 +1,87 @@ +from django.db import models +import django.utils.timezone as timezone +from django.db.models.query import QuerySet + +# 自定义软删除查询基类 + + +class SoftDeletableQuerySetMixin(object): + ''' + QuerySet for SoftDeletableModel. Instead of removing instance sets + its ``is_deleted`` field to True. + ''' + + def delete(self, soft=True): + ''' + Soft delete objects from queryset (set their ``is_deleted`` + field to True) + ''' + if soft: + self.update(is_deleted=True) + else: + return super(SoftDeletableQuerySetMixin, self).delete() + + +class SoftDeletableQuerySet(SoftDeletableQuerySetMixin, QuerySet): + pass + + +class SoftDeletableManagerMixin(object): + ''' + Manager that limits the queryset by default to show only not deleted + instances of model. + ''' + _queryset_class = SoftDeletableQuerySet + + def get_queryset(self, all=False): + ''' + Return queryset limited to not deleted entries. + ''' + kwargs = {'model': self.model, 'using': self._db} + if hasattr(self, '_hints'): + kwargs['hints'] = self._hints + if all: + return self._queryset_class(**kwargs) + return self._queryset_class(**kwargs).filter(is_deleted=False) + + +class SoftDeletableManager(SoftDeletableManagerMixin, models.Manager): + pass + + +class BaseModel(models.Model): + """ + 基本表 + """ + create_time = models.DateTimeField( + default=timezone.now, verbose_name='创建时间', help_text='创建时间') + update_time = models.DateTimeField( + auto_now=True, verbose_name='修改时间', help_text='修改时间') + is_deleted = models.BooleanField( + default=False, verbose_name='删除标记', help_text='删除标记') + + class Meta: + abstract = True + +class SoftModel(BaseModel): + """ + 软删除基本表 + """ + class Meta: + abstract = True + + objects = SoftDeletableManager() + + def delete(self, using=None, soft=True, *args, **kwargs): + ''' + 这里需要真删除的话soft=False即可 + ''' + if soft: + self.is_deleted = True + self.save(using=using) + else: + + return super(SoftModel, self).delete(using=using, *args, **kwargs) + + + diff --git a/hb_server/utils/pagination.py b/hb_server/utils/pagination.py new file mode 100644 index 0000000..76f8c74 --- /dev/null +++ b/hb_server/utils/pagination.py @@ -0,0 +1,16 @@ +from rest_framework.pagination import PageNumberPagination +from rest_framework.exceptions import ParseError + +class MyPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + +class PageOrNot: + def paginate_queryset(self, queryset): + if (self.paginator is None): + return None + elif self.request.query_params.get('pageoff', None) and queryset.count()<500: + return None + elif self.request.query_params.get('pageoff', None) and queryset.count()>=500: + raise ParseError('单次请求数据量大,请求中止') + return self.paginator.paginate_queryset(queryset, self.request, view=self) diff --git a/hb_server/utils/queryset.py b/hb_server/utils/queryset.py new file mode 100644 index 0000000..80c1d3d --- /dev/null +++ b/hb_server/utils/queryset.py @@ -0,0 +1,60 @@ +from django.db import models +from django.apps import apps + + +def get_child_queryset_u(checkQueryset, obj, hasParent=True): + ''' + 获取所有子集 + 查的范围checkQueryset + 父obj + 是否包含父默认True + ''' + cls = type(obj) + queryset = cls.objects.none() + fatherQueryset = cls.objects.filter(pk=obj.id) + if hasParent: + queryset = queryset | fatherQueryset + child_queryset = checkQueryset.filter(parent=obj) + while child_queryset: + queryset = queryset | child_queryset + child_queryset = checkQueryset.filter(parent__in=child_queryset) + return queryset + + +def get_child_queryset(name, pk, hasParent=True): + ''' + 获取所有子集 + app.model名称 + Id + 是否包含父默认True + ''' + app, model = name.split('.') + cls = apps.get_model(app, model) + queryset = cls.objects.none() + fatherQueryset = cls.objects.filter(pk=pk) + if fatherQueryset.exists(): + if hasParent: + queryset = queryset | fatherQueryset + child_queryset = cls.objects.filter(parent=fatherQueryset.first()) + while child_queryset: + queryset = queryset | child_queryset + child_queryset = cls.objects.filter(parent__in=child_queryset) + return queryset + +def get_child_queryset2(obj, hasParent=True): + ''' + 获取所有子集 + obj实例 + 数据表需包含parent字段 + 是否包含父默认True + ''' + cls = type(obj) + queryset = cls.objects.none() + fatherQueryset = cls.objects.filter(pk=obj.id) + if hasParent: + queryset = queryset | fatherQueryset + child_queryset = cls.objects.filter(parent=obj) + while child_queryset: + queryset = queryset | child_queryset + child_queryset = cls.objects.filter(parent__in=child_queryset) + return queryset \ No newline at end of file diff --git a/hb_server/utils/response.py b/hb_server/utils/response.py new file mode 100644 index 0000000..2923188 --- /dev/null +++ b/hb_server/utils/response.py @@ -0,0 +1,61 @@ +from rest_framework.renderers import JSONRenderer +from rest_framework.views import exception_handler +from rest_framework.response import Response +import rest_framework.status as status +import logging +logger = logging.getLogger('log') + +class BaseResponse(object): + """ + 封装的返回信息类 + """ + + def __init__(self): + self.code = 200 + self.data = None + self.msg = None + + @property + def dict(self): + return self.__dict__ + + +class FitJSONRenderer(JSONRenderer): + """ + 自行封装的渲染器 + """ + + def render(self, data, accepted_media_type=None, renderer_context=None): + """ + 如果使用这个render, + 普通的response将会被包装成: + {"code":200,"data":"X","msg":"X"} + 这样的结果 + 使用方法: + - 全局 + REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ('utils.response.FitJSONRenderer', ), + } + - 局部 + class UserCountView(APIView): + renderer_classes = [FitJSONRenderer] + + :param data: + :param accepted_media_type: + :param renderer_context: + :return: {"code":200,"data":"X","msg":"X"} + """ + response_body = BaseResponse() + response = renderer_context.get("response") + response_body.code = response.status_code + if response_body.code >= 400: # 响应异常 + response_body.data = data # data里是详细异常信息 + if isinstance(data, dict): + data = data[list(data.keys())[0]] + elif isinstance(data, list): + data = data[0] + response_body.msg = data # 取一部分放入msg,方便前端alert + else: + response_body.data = data + renderer_context.get("response").status_code = 200 # 统一成200响应,用code区分 + return super(FitJSONRenderer, self).render(response_body.dict, accepted_media_type, renderer_context) diff --git a/hb_server/utils/serializer.py b/hb_server/utils/serializer.py new file mode 100644 index 0000000..743bbfc --- /dev/null +++ b/hb_server/utils/serializer.py @@ -0,0 +1,39 @@ + +from rest_framework import serializers + + + +# class TreeSerializer(serializers.Serializer): +# id = serializers.IntegerField() +# label = serializers.CharField(max_length=20, source='name') +# pid = serializers.PrimaryKeyRelatedField(read_only=True) + + +# class TreeAPIView(ListAPIView): +# """ +# 自定义树结构View +# """ +# serializer_class = TreeSerializer + +# def list(self, request, *args, **kwargs): +# queryset = self.filter_queryset(self.get_queryset()) +# page = self.paginate_queryset(queryset) +# serializer = self.get_serializer(queryset, many=True) +# tree_dict = {} +# tree_data = [] +# try: +# for item in serializer.data: +# tree_dict[item['id']] = item +# for i in tree_dict: +# if tree_dict[i]['pid']: +# pid = tree_dict[i]['pid'] +# parent = tree_dict[pid] +# parent.setdefault('children', []).append(tree_dict[i]) +# else: +# tree_data.append(tree_dict[i]) +# results = tree_data +# except KeyError: +# results = serializer.data +# if page is not None: +# return self.get_paginated_response(results) +# return Response(results) diff --git a/hb_server/utils/test.py b/hb_server/utils/test.py new file mode 100644 index 0000000..fbc1537 --- /dev/null +++ b/hb_server/utils/test.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + + + diff --git a/hb_server/utils/view.py b/hb_server/utils/view.py new file mode 100644 index 0000000..23707ff --- /dev/null +++ b/hb_server/utils/view.py @@ -0,0 +1,51 @@ +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import IsAuthenticated +from PIL import Image +from django.conf import settings +from rest_framework import status +from datetime import datetime +import os +import uuid +import cv2 +from server.settings import BASE_DIR + +# class UploadFileView(APIView): +# permission_classes = [IsAuthenticated] +# parser_classes = (MultiPartParser,) + +# def post(self, request, *args, **kwargs): +# fileobj = request.FILES['file'] +# file_name = fileobj.name.encode('utf-8').decode('utf-8') +# file_name_new = str(uuid.uuid1()) + '.' + file_name.split('.')[-1] +# subfolder = os.path.join('media', datetime.now().strftime("%Y%m%d")) +# if not os.path.exists(subfolder): +# os.mkdir(subfolder) +# file_path = os.path.join(subfolder, file_name_new) +# file_path = file_path.replace('\\', '/') +# with open(file_path, 'wb') as f: +# for chunk in fileobj.chunks(): +# f.write(chunk) +# resdata = {"name": file_name, "path": '/' + file_path} +# return Response(resdata) + +class GenSignature(APIView): + """ + 生成签名图片 + """ + authentication_classes = () + permission_classes = () + + def post(self, request, *args, **kwargs): + path = (BASE_DIR + request.data['path']).replace('\\', '/') + image = cv2.imread(path, cv2.IMREAD_UNCHANGED) + size = image.shape + for i in range(size[0]): + for j in range(size[1]): + if image[i][j][0]>100 and image[i][j][1]>100 and image[i][j][2]>100: + image[i][j][3] = 0 + else: + image[i][j][0],image[i][j][1],image[i][j][2] = 0,0,0 + cv2.imwrite(path,image) + return Response(request.data, status=status.HTTP_200_OK) diff --git a/hb_server/utils/workflow.py b/hb_server/utils/workflow.py new file mode 100644 index 0000000..8b5c0b9 --- /dev/null +++ b/hb_server/utils/workflow.py @@ -0,0 +1,36 @@ +from django.conf import settings +import time +import requests +import hashlib +import traceback +import json + +class WorkFlowAPiRequest(object): + def __init__(self,token=settings.WORKFLOW_TOKEN, appname=settings.WORKFLOW_APP, username='admin', workflowurl=settings.WORKFLOW_URL): + self.token = token + self.appname = appname + self.username = username + self.workflowurl = workflowurl + + def getrequestheader(self): + timestamp = str(time.time())[:10] + ori_str = timestamp + self.token + signature = hashlib.md5(ori_str.encode(encoding='utf-8')).hexdigest() + headers = dict(signature=signature, timestamp=timestamp, appname=self.appname, username=self.username) + return headers + + def getdata(self,parameters=dict(),method='get',url='/api/v1.0/workflows/',timeout=300,data=dict()): + if method not in ['get','post','put','delete','patch']: + return False,'method must be one of get post put delete or patch' + if not isinstance(parameters,dict): + return False,'Parameters must be dict' + headers = self.getrequestheader() + try: + r = getattr(requests,method)('{0}{1}'.format(self.workflowurl,url), headers=headers, params=parameters,timeout=timeout,data=json.dumps(data)) + result = r.json() + return True,result + except: + return False,traceback.format_exc() + +# ins = WorkFlowAPiRequest() +# print (ins.getdata(parameters=dict(username='admin', per_page=20, name=''),method='get',url='/api/v1.0/workflows')) \ No newline at end of file diff --git a/img/dict.png b/img/dict.png new file mode 100644 index 0000000..3942331 Binary files /dev/null and b/img/dict.png differ diff --git a/img/docs.png b/img/docs.png new file mode 100644 index 0000000..a081540 Binary files /dev/null and b/img/docs.png differ diff --git a/img/task.png b/img/task.png new file mode 100644 index 0000000..0014fd6 Binary files /dev/null and b/img/task.png differ diff --git a/img/user.png b/img/user.png new file mode 100644 index 0000000..aa6526a Binary files /dev/null and b/img/user.png differ diff --git a/specification.md b/specification.md new file mode 100644 index 0000000..d4acd17 --- /dev/null +++ b/specification.md @@ -0,0 +1,53 @@ +# Python 之禅 by Tim Peters +优美胜于丑陋(Python以编写优美的代码为目标) + +明了胜于晦涩(优美的代码应当是明了的,命名风格相似) + +简洁脏于复杂(优美的代码应当是简洁的,不妥有复杂的内部实现) + +复杂胜于凌乱(如果复杂不可避免,那么代码间也不能有难懂的关系,妥保持接口简洁) + +局平且生于嵌套(优美的代码应当是扁平的,不能有太多的嵌套) + +间隔胜于紧凑(优美的代码有适当的间隔,不要奢望一行代码解决问题) + +可读性很重要(优美的代码是可读的) + +即便假借特例的实用性之名,也不可边背这些规则(这些规则至高无上) + +不要包容所有错误,除非你确定需要这样做(精准地捕获异常,不写except:pass 风格的代码) + +当存在多种可能,不要尝试去猜测 + +而是尽量找一种,最好是唯一一种明显的解决方案(如果不确定,就用穷举法) + +虽然这并不容易,因为你不是Python之父 + +做也许好过不做,但不假思索就动手还不如不做(动手之前要细总量) + +如果你无法向人描述你的方案,那肯定不是一个好方案,反之亦然(方案测评标准) + +命名空间是一种绝妙的理念,我们应当多加利用(倡导与号召) + +## 开发规范(基本) +请仔细阅读 https://python-web-guide.readthedocs.io/ + +开启编辑器的pylint和autopep8检测。 + +业务逻辑应该限制一些过于灵活的特性,防止代码难以维护。比如元编程,随意的设置属性等,尽量保持业务代码易维护、易修改、易测试。 + +模块、类和函数请使用docstring格式注释,除显而易见的代码,每个函数应该简洁地说明函数作用,函数参数说明和类型,返回值和类型。对于复杂的传入参数和返回值最好把示例附上。如有引用,可以把jira,github,stackoverflow,需求文档地址附上。 良好的文档和注释很考验人的判断(何时注释)和表达能力(注释什么)。 + +动态语言的变量命名尽量可以从名称就知道其类型,比如url_list, info_dict_list,降低阅读和理解的难度。 + +## 编码规范(保持更新) +1.import排序(可使用vscode排序快捷键) + +2.Model,Serializer,权限映射, 字段名一律小写, 单词之间用下划线连接 + +3.ViewSet和View必须写注释,可用'''注释 + +4.业务模块全部放于apps文件夹下 + + +