commit 9d4f78656be50bd1634807300912e7426d34c227 Author: admin Date: Mon Dec 8 14:39:07 2025 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c747d33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +.trae/ +.vscode/ +.idea/ +*.log +.env \ No newline at end of file diff --git a/INTERFACE_CONFIG.md b/INTERFACE_CONFIG.md new file mode 100644 index 0000000..461a545 --- /dev/null +++ b/INTERFACE_CONFIG.md @@ -0,0 +1,117 @@ +# 接口地址配置说明文档 + +本文档主要说明 GeminiWX 项目中服务端、管理后台(Web)、微信小程序的接口地址配置方法。 + +## 1. 服务端 (Server) + +服务端基于 Django 开发,主要配置文件位于 `admin/server` 目录。 + +### 核心配置 +- **配置文件路径**: `admin/server/server/settings.py` 和 `admin/server/server/conf_e.py` +- **端口配置**: 默认运行在 `8000` 端口(在 `docker-compose.yml` 或 `manage.py` 启动命令中指定)。 + +### 允许的主机 (Allowed Hosts) +在 `admin/server/server/settings.py` 中: +```python +ALLOWED_HOSTS = ['*'] # 允许所有域名访问,生产环境建议修改为具体域名 +``` + +### 微信 AppID 配置 +为了确保微信登录和支付等功能正常,需要在以下两处保持 `AppID` 一致: +1. **服务端**: `admin/server/server/conf_e.py` + ```python + WECHAT_APP_ID = 'wx2d9b9759137ef46b' # 示例 AppID + WECHAT_APP_SECRET = '...' + ``` +2. **小程序**: `wechat-mini-program/project.config.json` + ```json + { + "appid": "wx2d9b9759137ef46b", + ... + } + ``` + +### 数据库与密钥配置 +在 `admin/server/server/conf_e.py` 中配置数据库连接信息。 + +--- + +## 2. 管理后台 Web (Admin Client) + +管理后台基于 Vue.js 开发,位于 `admin/client` 目录。 + +### 开发环境 (Development) +- **配置文件**: `admin/client/.env.development` +- **配置项**: + ```properties + VUE_APP_BASE_API = '/api' + ``` +- **代理配置**: `admin/client/vue.config.js` + 开发环境下,`/api` 开头的请求会被代理到本地服务端: + ```javascript + proxy: { + '/api': { + target: 'http://192.168.5.81:8000', // 目标服务器地址 + changeOrigin: true + } + } + ``` + +### 预发布环境 (Staging) +- **配置文件**: `admin/client/.env.staging` +- **配置项**: + ```properties + VUE_APP_BASE_API = '/stage-api' + ``` +- **构建**: 运行 `npm run build:stage`(假设 `package.json` 中有对应 script,通常为 `vue-cli-service build --mode staging`)。 + +### 生产环境 (Production) +- **配置文件**: `admin/client/.env.production` +- **配置项**: + ```properties + VUE_APP_BASE_API = '/api' + ``` +- **构建**: 运行 `npm run build:prod` 后,生成的静态文件位于 `admin/server/dist` (配置于 `vue.config.js` 的 `outputDir`)。 + +--- + +## 3. 微信小程序 (WeChat Mini Program) + +微信小程序代码位于 `wechat-mini-program` 目录。 + +### 接口地址配置 +- **配置文件路径**: `wechat-mini-program/config/env.js` +- **配置内容**: + + 该文件根据 `env` 变量决定使用开发环境还是生产环境的配置。 + + ```javascript + const env = 'development' // 修改此处切换环境:'development' 或 'production' + + const configs = { + development: { + baseUrl: 'http://192.168.5.81:8000/api' // 开发环境接口地址,请修改为本机局域网IP + }, + production: { + baseUrl: 'https://your-domain.example.com/api' // 生产环境接口地址 + } + } + + module.exports = configs[env] + ``` + +### 注意事项 +1. **开发调试**: 确保手机和电脑在同一局域网,且 `baseUrl` 中的 IP 地址是电脑的局域网 IP(可通过 `ipconfig` 或 `ifconfig` 查看)。 +2. **真机调试**: 微信开发者工具中需勾选“不校验合法域名”选项,或者在微信后台配置合法的服务器域名。 + +--- + +## 4. 快速检查清单 + +| 项目 | 环境 | 配置文件 | 关键配置项 | 说明 | +| --- | --- | --- | --- | --- | +| **Web** | 开发 | `admin/client/.env.development` | `VUE_APP_BASE_API` | 默认为 `/api`,配合 `vue.config.js` 代理 | +| **Web** | 预发布 | `admin/client/.env.staging` | `VUE_APP_BASE_API` | 默认为 `/stage-api` | +| **Web** | 生产 | `admin/client/.env.production` | `VUE_APP_BASE_API` | 默认为 `/api` | +| **小程序** | 开发 | `wechat-mini-program/config/env.js` | `configs.development.baseUrl` | **需修改为本机 IP** | +| **小程序** | 生产 | `wechat-mini-program/config/env.js` | `configs.production.baseUrl` | 修改为线上域名 | diff --git a/PROJECT_INTRODUCTION.md b/PROJECT_INTRODUCTION.md new file mode 100644 index 0000000..8e2e7f5 --- /dev/null +++ b/PROJECT_INTRODUCTION.md @@ -0,0 +1,119 @@ +# 项目介绍文档 + +## 1. 项目概述 + +本项目是一个基于 RBAC (Role-Based Access Control) 模型权限控制的中小型应用基础开发平台。项目采用前后端分离架构,旨在提供一套功能完善、易于扩展的后台管理系统解决方案。 + +项目包含三个主要部分: +* **Web 管理端**:提供完整的后台管理界面。 +* **后端 API 服务**:提供 RESTful API 接口及业务逻辑处理。 +* **移动端**:支持微信小程序及 H5,方便移动办公。 + +## 2. 技术栈 + +### 2.1 后端 (Server) +* **核心框架**: Django 3.2.23 +* **API 框架**: Django REST Framework (DRF) 3.12.4 +* **异步任务**: Celery 5.2.7 + Redis (用于定时任务、异步操作) +* **认证机制**: JWT (JSON Web Token) - djangorestframework-simplejwt +* **接口文档**: Swagger (drf-yasg) +* **数据库**: + * 开发环境默认使用 SQLite + * 生产环境及工作流模块建议使用 PostgreSQL +* **其他特性**: + * 支持 CORS 跨域 + * 使用 django-simple-history 实现审计/历史记录功能 + +### 2.2 前端 (Web Client) +* **核心框架**: Vue.js 2.6.10 +* **UI 组件库**: Element UI 2.15.14 +* **路由管理**: Vue Router 3.0.6 +* **状态管理**: Vuex 3.1.0 +* **HTTP 请求**: Axios +* **图表/可视化**: D3.js, Dagre-D3 (用于工作流展示等) +* **工具组件**: + * vue-treeselect (树形选择) + * xlsx (Excel 导入导出) + +### 2.3 移动端 (Mobile Client) +* **框架**: Uni-app (基于 Vue.js) +* **UI 库**: uView UI +* **发布目标**: 微信小程序, H5 + +## 3. 核心功能模块 + +### 3.1 系统管理 (System) +* **组织架构管理**: 支持多级组织机构管理。 +* **用户管理**: 用户的增删改查,支持分配角色和岗位。 +* **角色管理**: 基于 RBAC 的角色管理,支持功能权限分配。 +* **岗位管理**: 用户的职位/岗位管理。 +* **菜单/权限管理**: + * **功能权限**: 控制到每一个 API 接口的访问权限。 + * **数据权限**: 支持全部、本级及以下、同级及以下、本人等多种数据可见范围控制。 +* **数据字典**: 系统通用的字典数据管理。 +* **文件库**: 统一的文件上传和管理。 + +### 3.2 客户关系管理 (CRM) +* 包含基本的 CRM 功能模块(具体业务逻辑见 `apps/crm`)。 + +### 3.3 工作流 (Workflow) +* 基于 `loonflow` 引擎简化的工作流实现。 +* 支持工作流定义、状态流转。 +* **注意**: 后端代码位于 `apps/wf`,建议配合 PostgreSQL 使用以获得完整支持(SQLite 可能不支持部分 JSON 查询)。 + +### 3.4 系统监控 (Monitor) +* **服务监控**: 监控服务器状态、服务运行情况。 +* **定时任务**: 基于 Celery Beat 的定时任务管理。 +* **审计日志**: 记录用户操作历史。 + +## 4. 项目结构说明 + +```text +root/ +├── admin/ +│ ├── client/ # Web 前端项目源码 (Vue + ElementUI) +│ ├── client_mp/ # 移动端项目源码 (Uni-app + uView) +│ └── server/ # 后端项目源码 (Django) +│ ├── apps/ # 业务应用模块 (crm, monitor, system, wf) +│ ├── server/ # Django 项目配置 (settings, urls) +│ ├── utils/ # 通用工具类 +│ └── manage.py # Django 管理脚本 +├── wechat-mini-program/ # 原生微信小程序代码 (可能为旧版或独立模块) +└── docker-compose.yml # Docker 部署配置 +``` + +## 5. 快速开始 + +### 5.1 后端启动 (Windows) +1. 进入 `admin/server` 目录。 +2. 创建并激活虚拟环境: + ```bash + python -m venv venv + .\venv\scripts\activate + ``` +3. 安装依赖: `pip install -r requirements.txt` +4. 配置数据库: 复制 `conf_e.py` 为 `conf.py` 并配置数据库连接(默认可使用 SQLite)。 +5. 初始化数据库: + ```bash + python manage.py migrate + python manage.py loaddata db.json # 导入初始数据 + ``` +6. 创建超级用户: `python manage.py createsuperuser` +7. 启动服务: `python manage.py runserver 8000` +8. 接口文档地址: `http://localhost:8000/api/swagger/` + +### 5.2 前端启动 +1. 进入 `admin/client` 目录。 +2. 安装依赖: `npm install --registry=https://registry.npmmirror.com` +3. 启动开发服务: `npm run dev` +4. 访问地址: `http://localhost:9528` + +## 6. 部署建议 + +* **生产环境**: 建议前后端分离部署,使用 Nginx 进行反向代理。 +* **后端**: 使用 Gunicorn 或 uWSGI 运行 Django 服务,配合 Supervisor 进行进程守护。 +* **前端**: 运行 `npm run build:prod` 打包生成静态文件,由 Nginx 托管。 +* **WebSocket**: 如需支持 WebSocket 功能,需配置 Daphne 并通过 Supervisor 运行。 + +## 7. 参考资料 +* 更多复杂功能参考原仓库: [https://github.com/caoqianming/xt_server](https://github.com/caoqianming/xt_server) diff --git a/STARTUP.md b/STARTUP.md new file mode 100644 index 0000000..89e8e2a --- /dev/null +++ b/STARTUP.md @@ -0,0 +1,75 @@ +# 项目启动文档 + +本文档说明如何启动 GeminiWX 项目的服务端、管理后台和小程序。 + +## 1. 目录结构 + +* `admin/server`: Django 后端服务 +* `admin/client`: Vue.js 管理后台前端 +* `wechat-mini-program`: 微信小程序前端代码 + +## 2. 启动服务端 (Django) + +服务端基于 Django 框架。 + +### 步骤 + +1. 打开终端。 +2. 进入项目根目录。 +3. 使用 `admin/server/.venv` 中的 Python 解释器启动服务: + + ```powershell + .\admin\server\.venv\Scripts\python.exe admin/server/manage.py runserver 0.0.0.0:8000 + ``` + + 或者,如果你已经激活了虚拟环境: + + ```powershell + cd admin/server + python manage.py runserver 0.0.0.0:8000 + ``` + +4. 服务启动后,访问 `http://localhost:8000/` 确认运行正常。 + +## 3. 启动管理后台 (Vue.js) + +管理后台基于 Vue Element Admin 模板。 + +### 步骤 + +1. 打开新的终端窗口。 +2. 进入前端目录: + + ```powershell + cd admin/client + ``` + +3. 安装依赖(如果尚未安装): + + ```powershell + npm install + ``` + +4. 启动开发服务器: + + ```powershell + npm run dev + ``` + +5. 启动完成后,浏览器会自动打开 `http://localhost:9528` (或其他配置的端口)。 + +## 4. 启动微信小程序 + +### 步骤 + +1. 下载并安装 [微信开发者工具](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html)。 +2. 打开微信开发者工具,选择“导入项目”。 +3. 选择目录 `wechat-mini-program`。 +4. 设置 AppID(如果没有,可以使用测试号)。 +5. 在开发者工具中编译并预览。 + +## 注意事项 + +* **端口占用**: 确保 8000 (后端) 和 9528 (前端) 端口未被占用。 +* **数据库**: 项目默认使用 `db.sqlite3`,无需额外配置数据库服务。 +* **Python 环境**: 请使用项目自带的虚拟环境 `admin/server/.venv` 以确保依赖正确。 diff --git a/admin/.gitattributes b/admin/.gitattributes new file mode 100644 index 0000000..6e51dee --- /dev/null +++ b/admin/.gitattributes @@ -0,0 +1 @@ +*.vue linguist-language=python diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..fee402c --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,8 @@ +unpackage/dist/* +node_modules/* +deploy.sh +package-lock.json +.idea/ +.vscode/ +server/static/ +img/wechat_group.jpg diff --git a/admin/LICENSE b/admin/LICENSE new file mode 100644 index 0000000..09310e6 --- /dev/null +++ b/admin/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/admin/README.md b/admin/README.md new file mode 100644 index 0000000..5ee5885 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,195 @@ +# 简介 +基于RBAC模型权限控制的中小型应用的基础开发平台,前后端分离,后端采用django+django-rest-framework,前端采用vue+ElementUI,移动端采用uniapp+uView(可发布h5和小程序). + +JWT认证,可使用simple_history实现审计功能,支持swagger + +内置模块有组织机构\用户\角色\岗位\数据字典\文件库\定时任务\工作流(已上传大部分代码, 后端代码位于apps/wf) + +使用工作流建议数据库用Postgresql, 下面的预览环境因为是用的sqlite因此有些json查询不支持, 使用方法可参考loonflow文档基本是一致, 主要是做了简化 + +支持功能权限(控权到每个接口)和简单的数据权限(全部、本级及以下、同级及以下、本人等) + +----- + +若需要更复杂的一些功能,请查看仓库 + + +其在master分支的后端基础上进行了重新编写,拥有更完善的权限控制、工作流引擎、运维管理、websocket支持并提供多种常用功能集成到utils中. + + +## 部分截图 +![image](https://github.com/caoqianming/django-vue-admin/blob/master/img/ticket.png) +![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) + +## 预览地址 +预览地址直接使用的runserver,账户admin,密码admin。请谨慎操作,勿修改密码 + + + +## 启动(以下是在windows下开发操作步骤) + + +### django后端 +定位到server文件夹 + +建立虚拟环境 `python -m venv venv` + +激活虚拟环境 `.\venv\scripts\activate` + +安装依赖包 `pip install -r requirements.txt` + +复制server文件夹下的conf_e.py为conf.py +根据需要修改里面的数据库连接及DEBUG参数 + +同步数据库 `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.npmmirror.com` + +运行服务 `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/api/swagger/ + +后台地址 localhost:8000/django/admin/ + +## 部署 +部署时注意修改conf.py + +可以前后端分开部署, nginx代理。也可打包之后将前端dist替换server/dist, 然后执行collectstatic + +使用gunicorn启动: 进入虚拟环境执行 gunicorn -w 5 -b 0.0.0.0:2251 server.wsgi + +如果需要webscoket还需要配置daphne启动,可使用supervisor监控 + +Nginx配置可参考如下: +``` +server { + listen 2250; + client_max_body_size 1024m; + location /media/ { + alias /home/lighthouse/xx/media/; + limit_rate 800k; + } + location / { + alias /home/lighthouse/xx/dist/; + index index.html; + } + location ~ ^/(api|django)/ { + set $CSRFTOKEN ""; + if ($http_cookie ~* "CSRFTOKEN=(.+?)(?=;|$)") { + set $CSRFTOKEN "$1"; + } + proxy_set_header X-CSRFToken $CSRFTOKEN; + proxy_pass http://localhost:2251; + proxy_pass_header Authorization; + proxy_pass_header WWW-Authenticate; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + location /ws/ { + proxy_pass http://localhost:2252; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + } + +} +``` + +### 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方法;根据需要使用即可 + +由于实际情况比较复杂,这里建议根据不同情况自己写drf的permission_class + +### 关于定时任务 +使用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` + +### 工作流 +工作流模块参考loonflow的实现可查看其文档(逻辑一样, 感谢loonflow) +目前大部分代码已上传, 可查看swagger + +### 微信群 +愿意交流的话 +可以加QQ群 235665873 +可以加微信群 +![image](http://49.232.14.174:7777/media/wechat_group.jpg) diff --git a/admin/client/.editorconfig b/admin/client/.editorconfig new file mode 100644 index 0000000..ea6e20f --- /dev/null +++ b/admin/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/admin/client/.env.development b/admin/client/.env.development new file mode 100644 index 0000000..f67b802 --- /dev/null +++ b/admin/client/.env.development @@ -0,0 +1,14 @@ +# just a flag +ENV = 'development' + +# base api +VUE_APP_BASE_API = '/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/admin/client/.env.production b/admin/client/.env.production new file mode 100644 index 0000000..07d391e --- /dev/null +++ b/admin/client/.env.production @@ -0,0 +1,6 @@ +# just a flag +ENV = 'production' + +# base api +VUE_APP_BASE_API = '/api' + diff --git a/admin/client/.env.staging b/admin/client/.env.staging new file mode 100644 index 0000000..a8793a0 --- /dev/null +++ b/admin/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/admin/client/.eslintignore b/admin/client/.eslintignore new file mode 100644 index 0000000..e6529fc --- /dev/null +++ b/admin/client/.eslintignore @@ -0,0 +1,4 @@ +build/*.js +src/assets +public +dist diff --git a/admin/client/.eslintrc.js b/admin/client/.eslintrc.js new file mode 100644 index 0000000..c977505 --- /dev/null +++ b/admin/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/admin/client/.gitignore b/admin/client/.gitignore new file mode 100644 index 0000000..9ad28d2 --- /dev/null +++ b/admin/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/admin/client/.travis.yml b/admin/client/.travis.yml new file mode 100644 index 0000000..f4be7a0 --- /dev/null +++ b/admin/client/.travis.yml @@ -0,0 +1,5 @@ +language: node_js +node_js: 10 +script: npm run test +notifications: + email: false diff --git a/admin/client/Dockerfile b/admin/client/Dockerfile new file mode 100644 index 0000000..35534e1 --- /dev/null +++ b/admin/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/admin/client/Dockerfile_dev b/admin/client/Dockerfile_dev new file mode 100644 index 0000000..6f5a6ad --- /dev/null +++ b/admin/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/admin/client/LICENSE b/admin/client/LICENSE new file mode 100644 index 0000000..6151575 --- /dev/null +++ b/admin/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/admin/client/README-zh.md b/admin/client/README-zh.md new file mode 100644 index 0000000..d248632 --- /dev/null +++ b/admin/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/admin/client/README.md b/admin/client/README.md new file mode 100644 index 0000000..b99f942 --- /dev/null +++ b/admin/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/admin/client/babel.config.js b/admin/client/babel.config.js new file mode 100644 index 0000000..ba17966 --- /dev/null +++ b/admin/client/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + presets: [ + '@vue/app' + ] +} diff --git a/admin/client/build/index.js b/admin/client/build/index.js new file mode 100644 index 0000000..0c57de2 --- /dev/null +++ b/admin/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/admin/client/jest.config.js b/admin/client/jest.config.js new file mode 100644 index 0000000..143cdc8 --- /dev/null +++ b/admin/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/admin/client/jsconfig.json b/admin/client/jsconfig.json new file mode 100644 index 0000000..ed079e2 --- /dev/null +++ b/admin/client/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/admin/client/mock/index.js b/admin/client/mock/index.js new file mode 100644 index 0000000..90e2ffe --- /dev/null +++ b/admin/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/admin/client/mock/mock-server.js b/admin/client/mock/mock-server.js new file mode 100644 index 0000000..4c4cb2a --- /dev/null +++ b/admin/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/admin/client/mock/table.js b/admin/client/mock/table.js new file mode 100644 index 0000000..ba95f76 --- /dev/null +++ b/admin/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/admin/client/mock/user.js b/admin/client/mock/user.js new file mode 100644 index 0000000..f007cd9 --- /dev/null +++ b/admin/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/admin/client/package.json b/admin/client/package.json new file mode 100644 index 0000000..2cbd789 --- /dev/null +++ b/admin/client/package.json @@ -0,0 +1,75 @@ +{ + "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": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve", + "dev:docker": "NODE_ENV=development PORT=80 ./node_modules/.bin/vue-cli-service serve", + "build:prod": "set NODE_OPTIONS=--openssl-legacy-provider && 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": "1.6", + "d3": "^7.6.1", + "dagre-d3": "^0.6.4", + "echarts": "^6.0.0", + "element-ui": "2.15.14", + "file-saver": "^2.0.2", + "js-cookie": "2.2.0", + "normalize.css": "7.0.0", + "nprogress": "0.2.0", + "path-to-regexp": "3.3.0", + "vue": "2.6.10", + "vue-count-to": "^1.0.13", + "vue-json-editor": "^1.4.3", + "vue-router": "3.0.6", + "vuex": "3.1.0", + "wangeditor": "^4.7.15", + "webpack": "^4.36.0", + "xlsx": "^0.15.5" + }, + "devDependencies": { + "@babel/core": "^7.4.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.8.8", + "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.1.0", + "runjs": "^4.3.2", + "sass": "^1.69.5", + "sass-loader": "^8.0", + "script-ext-html-webpack-plugin": "2.1.3", + "script-loader": "0.7.2", + "serve-static": "^1.15.0", + "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/admin/client/postcss.config.js b/admin/client/postcss.config.js new file mode 100644 index 0000000..10473ef --- /dev/null +++ b/admin/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/admin/client/public/favicon.ico b/admin/client/public/favicon.ico new file mode 100644 index 0000000..34b63ac Binary files /dev/null and b/admin/client/public/favicon.ico differ diff --git a/admin/client/public/index.html b/admin/client/public/index.html new file mode 100644 index 0000000..fa2be91 --- /dev/null +++ b/admin/client/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + <%= webpackConfig.name %> + + + +
+ + + diff --git a/admin/client/src/App.vue b/admin/client/src/App.vue new file mode 100644 index 0000000..ec9032c --- /dev/null +++ b/admin/client/src/App.vue @@ -0,0 +1,11 @@ + + + diff --git a/admin/client/src/api/crm.js b/admin/client/src/api/crm.js new file mode 100644 index 0000000..48685e4 --- /dev/null +++ b/admin/client/src/api/crm.js @@ -0,0 +1,360 @@ +import request from '@/utils/request' + +// Teachers +export function getTeachers(query) { + return request({ + url: '/teachers/', + method: 'get', + params: query + }) +} + +export function createTeacher(data) { + return request({ + url: '/teachers/', + method: 'post', + data + }) +} + +export function updateTeacher(id, data) { + return request({ + url: `/teachers/${id}/`, + method: 'put', + data + }) +} + +export function deleteTeacher(id) { + return request({ + url: `/teachers/${id}/`, + method: 'delete' + }) +} + +// Teaching Centers +export function getTeachingCenters(query) { + return request({ + url: '/teaching-centers/', + method: 'get', + params: query + }) +} + +export function createTeachingCenter(data) { + return request({ + url: '/teaching-centers/', + method: 'post', + data + }) +} + +export function updateTeachingCenter(id, data) { + return request({ + url: `/teaching-centers/${id}/`, + method: 'put', + data + }) +} + +export function deleteTeachingCenter(id) { + return request({ + url: `/teaching-centers/${id}/`, + method: 'delete' + }) +} + +// Categories +export function getCategories(query) { + return request({ + url: '/categories/', + method: 'get', + params: query + }) +} + +export function createCategory(data) { + return request({ + url: '/categories/', + method: 'post', + data + }) +} + +export function updateCategory(id, data) { + return request({ + url: `/categories/${id}/`, + method: 'put', + data + }) +} + +export function deleteCategory(id) { + return request({ + url: `/categories/${id}/`, + method: 'delete' + }) +} + +// Projects +export function getProjects(query) { + return request({ + url: '/projects/', + method: 'get', + params: query + }) +} + +export function createProject(data) { + return request({ + url: '/projects/', + method: 'post', + data + }) +} + +export function updateProject(id, data) { + return request({ + url: `/projects/${id}/`, + method: 'put', + data + }) +} + +export function deleteProject(id) { + return request({ + url: `/projects/${id}/`, + method: 'delete' + }) +} + +// Coupons +export function getCoupons(query) { + return request({ + url: '/coupons/', + method: 'get', + params: query + }) +} + +export function createCoupon(data) { + return request({ + url: '/coupons/', + method: 'post', + data + }) +} + +export function updateCoupon(id, data) { + return request({ + url: `/coupons/${id}/`, + method: 'put', + data + }) +} + +export function deleteCoupon(id) { + return request({ + url: `/coupons/${id}/`, + method: 'delete' + }) +} + +// Banners +export function getBanners(query) { + return request({ + url: '/banners/', + method: 'get', + params: query + }) +} + +export function createBanner(data) { + return request({ + url: '/banners/', + method: 'post', + data + }) +} + +export function updateBanner(id, data) { + return request({ + url: `/banners/${id}/`, + method: 'put', + data + }) +} + +export function deleteBanner(id) { + return request({ + url: `/banners/${id}/`, + method: 'delete' + }) +} + +// Students +export function getStudents(query) { + return request({ + url: '/students/', + method: 'get', + params: query + }) +} + +export function createStudent(data) { + return request({ + url: '/students/', + method: 'post', + data + }) +} + +export function updateStudent(id, data) { + return request({ + url: `/students/${id}/`, + method: 'put', + data + }) +} + +export function deleteStudent(id) { + return request({ + url: `/students/${id}/`, + method: 'delete' + }) +} + +export function clearStudents() { + return request({ + url: '/students/clear/', + method: 'delete' + }) +} + +export function assignCouponToStudent(id, couponId) { + return request({ + url: `/students/${id}/assign_coupon/`, + method: 'post', + data: { coupon_id: couponId } + }) +} + +// Student Coupons (Issued Coupons) +export function getStudentCoupons(query) { + return request({ + url: '/student-coupons/', + method: 'get', + params: query + }) +} + +export function updateStudentCoupon(id, data) { + return request({ + url: `/student-coupons/${id}/`, + method: 'patch', + data + }) +} + +export function deleteStudentCoupon(id) { + return request({ + url: `/student-coupons/${id}/`, + method: 'delete' + }) +} + +// Student Projects (Enrollments) +export function getStudentProjects(query) { + return request({ + url: '/student-projects/', + method: 'get', + params: query + }) +} + +export function createStudentProject(data) { + return request({ + url: '/student-projects/', + method: 'post', + data + }) +} + +export function updateStudentProject(id, data) { + return request({ + url: `/student-projects/${id}/`, + method: 'patch', + data + }) +} + +export function deleteStudentProject(id) { + return request({ + url: `/student-projects/${id}/`, + method: 'delete' + }) +} + +// Student Honors +export function getHonors(query) { + return request({ + url: '/student-honors/', + method: 'get', + params: query + }) +} + +export function createHonor(data) { + return request({ + url: '/student-honors/', + method: 'post', + data + }) +} + +export function updateHonor(id, data) { + return request({ + url: `/student-honors/${id}/`, + method: 'put', + data + }) +} + +export function deleteHonor(id) { + return request({ + url: `/student-honors/${id}/`, + method: 'delete' + }) +} + +// Student Showcases +export function getShowcases(query) { + return request({ + url: '/student-showcases/', + method: 'get', + params: query + }) +} + +export function createShowcase(data) { + return request({ + url: '/student-showcases/', + method: 'post', + data + }) +} + +export function updateShowcase(id, data) { + return request({ + url: `/student-showcases/${id}/`, + method: 'put', + data + }) +} + +export function deleteShowcase(id) { + return request({ + url: `/student-showcases/${id}/`, + method: 'delete' + }) +} diff --git a/admin/client/src/api/dashboard.js b/admin/client/src/api/dashboard.js new file mode 100644 index 0000000..d72e70e --- /dev/null +++ b/admin/client/src/api/dashboard.js @@ -0,0 +1,8 @@ +import request from '@/utils/request' + +export function getDashboardStats() { + return request({ + url: '/dashboard/stats', + method: 'get' + }) +} diff --git a/admin/client/src/api/dict.js b/admin/client/src/api/dict.js new file mode 100644 index 0000000..d46429f --- /dev/null +++ b/admin/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/admin/client/src/api/file.js b/admin/client/src/api/file.js new file mode 100644 index 0000000..99587b1 --- /dev/null +++ b/admin/client/src/api/file.js @@ -0,0 +1,32 @@ +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 + }) +} + +export function deleteFile(id) { + return request({ + url: '/file/' + id + '/', + method: 'delete' + }) +} + +export function clearFiles() { + return request({ + url: '/file/clear_all/', + method: 'delete' + }) +} diff --git a/admin/client/src/api/moritor.js b/admin/client/src/api/moritor.js new file mode 100644 index 0000000..659ca27 --- /dev/null +++ b/admin/client/src/api/moritor.js @@ -0,0 +1,26 @@ +import { getToken } from "@/utils/auth" +import request from '@/utils/request' + +//查看日志列表 + +export function getlogList(query) { + return request({ + url: '/monitor/log/', + method: 'get', + params: query + }) +} +//查看日志详情 +export function getLog(name) { + return request({ + url: `/monitor/log/${name}/`, + method: 'get' + }) +} +//获取服务器状态信息 +export function getServerList() { + return request({ + url: '/monitor/server/', + method: 'get' + }) +} \ No newline at end of file diff --git a/admin/client/src/api/org.js b/admin/client/src/api/org.js new file mode 100644 index 0000000..2880ec4 --- /dev/null +++ b/admin/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/admin/client/src/api/perm.js b/admin/client/src/api/perm.js new file mode 100644 index 0000000..29ebf53 --- /dev/null +++ b/admin/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/admin/client/src/api/position.js b/admin/client/src/api/position.js new file mode 100644 index 0000000..2ea116f --- /dev/null +++ b/admin/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/admin/client/src/api/role.js b/admin/client/src/api/role.js new file mode 100644 index 0000000..e25e097 --- /dev/null +++ b/admin/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/admin/client/src/api/table.js b/admin/client/src/api/table.js new file mode 100644 index 0000000..2752f52 --- /dev/null +++ b/admin/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/admin/client/src/api/task.js b/admin/client/src/api/task.js new file mode 100644 index 0000000..1e3b397 --- /dev/null +++ b/admin/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/admin/client/src/api/user.js b/admin/client/src/api/user.js new file mode 100644 index 0000000..8edf3ab --- /dev/null +++ b/admin/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/admin/client/src/api/workflow.js b/admin/client/src/api/workflow.js new file mode 100644 index 0000000..f5a13de --- /dev/null +++ b/admin/client/src/api/workflow.js @@ -0,0 +1,258 @@ +import request from '@/utils/request' + +export function getWorkflowList(query) { + return request({ + url: '/wf/workflow/', + method: 'get', + params: query + }) +} +export function createWorkflow(data) { + return request({ + url: '/wf/workflow/', + method: 'post', + data + }) +} +export function updateWorkflow(id, data) { + return request({ + url: `/wf/workflow/${id}/`, + method: 'put', + data + }) +} +export function deleteWorkflow(id, data) { + return request({ + url: `/wf/workflow/${id}/`, + method: 'delete', + data + }) +} +//流转状态列表 +export function getWfStateList(id) { + return request({ + url: `/wf/workflow/${id}/states`, + method: 'get' + }) +} +//工单流转step +export function getWfFlowSteps(id) { + return request({ + url: `/wf/ticket/${id}/flowsteps/`, + method: 'get' + }) +} + +//流转状态创建 +export function createWfState(data) { + return request({ + url: '/wf/state/', + method: 'post', + data + }) +} +//处理工单 +export function ticketHandle(id,data) { + return request({ + url: `/wf/ticket/${id}/handle/`, + method: 'post', + data + }) +} + +//流转状态更新 +export function updateWfState(id, data) { + return request({ + url: `/wf/state/${id}/`, + method: 'put', + data + }) +} +//流转状态删除 +export function deleteWfState(id, data) { + return request({ + url: `/wf/state/${id}/`, + method: 'delete', + data + }) +} +//自定义字段列表 +export function getWfCustomfieldList(id) { + return request({ + url: `/wf/workflow/${id}/customfields`, + method: 'get' + }) +} +//自定义字段创建 +export function createWfCustomfield(data) { + return request({ + url: '/wf/customfield/', + method: 'post', + data + }) +} +//自定义字段更新 +export function updateWfCustomfield(id, data) { + return request({ + url: `/wf/customfield/${id}/`, + method: 'put', + data + }) +} +//自定义字段删除 +export function deleteWfCustomfield(id, data) { + return request({ + url: `/wf/customfield/${id}/`, + method: 'delete', + data + }) +} +//流转列表 +export function getWfTransitionList(id) { + return request({ + url: `/wf/workflow/${id}/transitions/`, + method: 'get' + }) +} +//流转创建 +export function createWfTransition(data) { + return request({ + url: '/wf/transition/', + method: 'post', + data + }) +} +//流转更新 +export function updateWfTransition(id, data) { + return request({ + url: `/wf/transition/${id}/`, + method: 'put', + data + }) +} +//流转删除 +export function deleteWfTransition(id, data) { + return request({ + url: `/wf/transition/${id}/`, + method: 'delete', + data + }) +} +//工单列表 +export function getTickets(query) { + return request({ + url: `/wf/ticket/`, + method: 'get', + params:query + }) +} +//新建工单 +export function createTicket(data) { + return request({ + url: '/wf/ticket/', + method: 'post', + data + }) +} + //详情 +export function ticketread(id) { + return request({ + url: `/wf/ticket/${id}/`, + method: 'get', + + }) + +} +//接单 +export function ticketAccpet(id,data) { + return request({ + url: `/wf/ticket/${id}/accpet/`, + method: 'post', + data + }) +} +//撤回工单,允许创建人在指定状态撤回工单至初始状态 +export function ticketRetreat(id,data) { + return request({ + url: `/wf/ticket/${id}/retreat/`, + method: 'post', + data + }) +} +//关闭工单,仅允许创建人在初始状态关闭工单 +export function ticketAddNode(id,data) { + return request({ + url: `/wf/ticket/${id}/add_node/`, + method: 'post', + data + }) +} +//加签 +export function ticketClose(id,data) { + return request({ + url: `/wf/ticket/${id}/close/`, + method: 'post', + data + }) +} +//加签 +export function ticketAddNodeEnd(id,data) { + return request({ + url: `/wf/ticket/${id}/add_node_end/`, + method: 'post', + data + }) +} +//工单删除 +export function ticketDestory(data) { + return request({ + url: `/wf/ticket/destory/`, + method: 'post', + data + }) +} +//工单详情 +export function getTicketDetail(id) { + return request({ + url: `/wf/ticket/${id}/`, + method: 'get' + }) +} + +//工单流转 +export function getTicketTransitions(id) { + return request({ + url: `/wf/ticket/${id}/transitions/`, + method: 'get' + }) +} + +//工单流转记录 +export function getTicketFlowlog(id) { + return request({ + url: `/wf/ticket/${id}/flowlogs/`, + method: 'get' + }) +} +//工单代办数量 +export function getCount(data) { + return request({ + url: `/wf/ticket/duty_agg/`, + method: 'get', + params:data + }) +} +//工单代办数量 +export function getCodes() { + return request({ + url: `/wf/participant_from_code`, + method: 'get' + }) +} +//工单详情 +export function getWorkflowInit(id) { + return request({ + url: `/wf/workflow/${id}/init/`, + method: 'get' + }) +} diff --git a/admin/client/src/assets/404_images/404.png b/admin/client/src/assets/404_images/404.png new file mode 100644 index 0000000..3d8e230 Binary files /dev/null and b/admin/client/src/assets/404_images/404.png differ diff --git a/admin/client/src/assets/404_images/404_cloud.png b/admin/client/src/assets/404_images/404_cloud.png new file mode 100644 index 0000000..c6281d0 Binary files /dev/null and b/admin/client/src/assets/404_images/404_cloud.png differ diff --git a/admin/client/src/components/Breadcrumb/index.vue b/admin/client/src/components/Breadcrumb/index.vue new file mode 100644 index 0000000..e65a60d --- /dev/null +++ b/admin/client/src/components/Breadcrumb/index.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/admin/client/src/components/Hamburger/index.vue b/admin/client/src/components/Hamburger/index.vue new file mode 100644 index 0000000..368b002 --- /dev/null +++ b/admin/client/src/components/Hamburger/index.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/admin/client/src/components/Pagination/index.vue b/admin/client/src/components/Pagination/index.vue new file mode 100644 index 0000000..e316e20 --- /dev/null +++ b/admin/client/src/components/Pagination/index.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/admin/client/src/components/SvgIcon/index.vue b/admin/client/src/components/SvgIcon/index.vue new file mode 100644 index 0000000..9a3318e --- /dev/null +++ b/admin/client/src/components/SvgIcon/index.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/admin/client/src/directive/el-table/adaptive.js b/admin/client/src/directive/el-table/adaptive.js new file mode 100644 index 0000000..298daea --- /dev/null +++ b/admin/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/admin/client/src/directive/el-table/index.js b/admin/client/src/directive/el-table/index.js new file mode 100644 index 0000000..d3d4515 --- /dev/null +++ b/admin/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/admin/client/src/icons/index.js b/admin/client/src/icons/index.js new file mode 100644 index 0000000..2c6b309 --- /dev/null +++ b/admin/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/admin/client/src/icons/svg/404.svg b/admin/client/src/icons/svg/404.svg new file mode 100644 index 0000000..6df5019 --- /dev/null +++ b/admin/client/src/icons/svg/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/bug.svg b/admin/client/src/icons/svg/bug.svg new file mode 100644 index 0000000..05a150d --- /dev/null +++ b/admin/client/src/icons/svg/bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/chart.svg b/admin/client/src/icons/svg/chart.svg new file mode 100644 index 0000000..27728fb --- /dev/null +++ b/admin/client/src/icons/svg/chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/clipboard.svg b/admin/client/src/icons/svg/clipboard.svg new file mode 100644 index 0000000..90923ff --- /dev/null +++ b/admin/client/src/icons/svg/clipboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/component.svg b/admin/client/src/icons/svg/component.svg new file mode 100644 index 0000000..207ada3 --- /dev/null +++ b/admin/client/src/icons/svg/component.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/dashboard.svg b/admin/client/src/icons/svg/dashboard.svg new file mode 100644 index 0000000..5317d37 --- /dev/null +++ b/admin/client/src/icons/svg/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/documentation.svg b/admin/client/src/icons/svg/documentation.svg new file mode 100644 index 0000000..7043122 --- /dev/null +++ b/admin/client/src/icons/svg/documentation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/drag.svg b/admin/client/src/icons/svg/drag.svg new file mode 100644 index 0000000..4185d3c --- /dev/null +++ b/admin/client/src/icons/svg/drag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/edit.svg b/admin/client/src/icons/svg/edit.svg new file mode 100644 index 0000000..d26101f --- /dev/null +++ b/admin/client/src/icons/svg/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/education.svg b/admin/client/src/icons/svg/education.svg new file mode 100644 index 0000000..7bfb01d --- /dev/null +++ b/admin/client/src/icons/svg/education.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/email.svg b/admin/client/src/icons/svg/email.svg new file mode 100644 index 0000000..74d25e2 --- /dev/null +++ b/admin/client/src/icons/svg/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/example.svg b/admin/client/src/icons/svg/example.svg new file mode 100644 index 0000000..46f42b5 --- /dev/null +++ b/admin/client/src/icons/svg/example.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/excel.svg b/admin/client/src/icons/svg/excel.svg new file mode 100644 index 0000000..74d97b8 --- /dev/null +++ b/admin/client/src/icons/svg/excel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/exit-fullscreen.svg b/admin/client/src/icons/svg/exit-fullscreen.svg new file mode 100644 index 0000000..485c128 --- /dev/null +++ b/admin/client/src/icons/svg/exit-fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/eye-open.svg b/admin/client/src/icons/svg/eye-open.svg new file mode 100644 index 0000000..88dcc98 --- /dev/null +++ b/admin/client/src/icons/svg/eye-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/eye.svg b/admin/client/src/icons/svg/eye.svg new file mode 100644 index 0000000..16ed2d8 --- /dev/null +++ b/admin/client/src/icons/svg/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/form.svg b/admin/client/src/icons/svg/form.svg new file mode 100644 index 0000000..dcbaa18 --- /dev/null +++ b/admin/client/src/icons/svg/form.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/fullscreen.svg b/admin/client/src/icons/svg/fullscreen.svg new file mode 100644 index 0000000..0e86b6f --- /dev/null +++ b/admin/client/src/icons/svg/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/guide.svg b/admin/client/src/icons/svg/guide.svg new file mode 100644 index 0000000..b271001 --- /dev/null +++ b/admin/client/src/icons/svg/guide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/icon.svg b/admin/client/src/icons/svg/icon.svg new file mode 100644 index 0000000..82be8ee --- /dev/null +++ b/admin/client/src/icons/svg/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/international.svg b/admin/client/src/icons/svg/international.svg new file mode 100644 index 0000000..e9b56ee --- /dev/null +++ b/admin/client/src/icons/svg/international.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/language.svg b/admin/client/src/icons/svg/language.svg new file mode 100644 index 0000000..0082b57 --- /dev/null +++ b/admin/client/src/icons/svg/language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/link.svg b/admin/client/src/icons/svg/link.svg new file mode 100644 index 0000000..48197ba --- /dev/null +++ b/admin/client/src/icons/svg/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/list.svg b/admin/client/src/icons/svg/list.svg new file mode 100644 index 0000000..20259ed --- /dev/null +++ b/admin/client/src/icons/svg/list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/lock.svg b/admin/client/src/icons/svg/lock.svg new file mode 100644 index 0000000..74fee54 --- /dev/null +++ b/admin/client/src/icons/svg/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/medal.svg b/admin/client/src/icons/svg/medal.svg new file mode 100644 index 0000000..93be397 --- /dev/null +++ b/admin/client/src/icons/svg/medal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/message.svg b/admin/client/src/icons/svg/message.svg new file mode 100644 index 0000000..14ca817 --- /dev/null +++ b/admin/client/src/icons/svg/message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/money.svg b/admin/client/src/icons/svg/money.svg new file mode 100644 index 0000000..c1580de --- /dev/null +++ b/admin/client/src/icons/svg/money.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/nested.svg b/admin/client/src/icons/svg/nested.svg new file mode 100644 index 0000000..06713a8 --- /dev/null +++ b/admin/client/src/icons/svg/nested.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/password.svg b/admin/client/src/icons/svg/password.svg new file mode 100644 index 0000000..e291d85 --- /dev/null +++ b/admin/client/src/icons/svg/password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/pdf.svg b/admin/client/src/icons/svg/pdf.svg new file mode 100644 index 0000000..957aa0c --- /dev/null +++ b/admin/client/src/icons/svg/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/people.svg b/admin/client/src/icons/svg/people.svg new file mode 100644 index 0000000..2bd54ae --- /dev/null +++ b/admin/client/src/icons/svg/people.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/peoples.svg b/admin/client/src/icons/svg/peoples.svg new file mode 100644 index 0000000..aab852e --- /dev/null +++ b/admin/client/src/icons/svg/peoples.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/position.svg b/admin/client/src/icons/svg/position.svg new file mode 100644 index 0000000..f89f0e0 --- /dev/null +++ b/admin/client/src/icons/svg/position.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/qq.svg b/admin/client/src/icons/svg/qq.svg new file mode 100644 index 0000000..ee13d4e --- /dev/null +++ b/admin/client/src/icons/svg/qq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/search.svg b/admin/client/src/icons/svg/search.svg new file mode 100644 index 0000000..84233dd --- /dev/null +++ b/admin/client/src/icons/svg/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/shopping.svg b/admin/client/src/icons/svg/shopping.svg new file mode 100644 index 0000000..87513e7 --- /dev/null +++ b/admin/client/src/icons/svg/shopping.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/size.svg b/admin/client/src/icons/svg/size.svg new file mode 100644 index 0000000..ddb25b8 --- /dev/null +++ b/admin/client/src/icons/svg/size.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/skill.svg b/admin/client/src/icons/svg/skill.svg new file mode 100644 index 0000000..a3b7312 --- /dev/null +++ b/admin/client/src/icons/svg/skill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/star.svg b/admin/client/src/icons/svg/star.svg new file mode 100644 index 0000000..6cf86e6 --- /dev/null +++ b/admin/client/src/icons/svg/star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/tab.svg b/admin/client/src/icons/svg/tab.svg new file mode 100644 index 0000000..b4b48e4 --- /dev/null +++ b/admin/client/src/icons/svg/tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/table.svg b/admin/client/src/icons/svg/table.svg new file mode 100644 index 0000000..0e3dc9d --- /dev/null +++ b/admin/client/src/icons/svg/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/theme.svg b/admin/client/src/icons/svg/theme.svg new file mode 100644 index 0000000..5982a2f --- /dev/null +++ b/admin/client/src/icons/svg/theme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/tree-table.svg b/admin/client/src/icons/svg/tree-table.svg new file mode 100644 index 0000000..8aafdb8 --- /dev/null +++ b/admin/client/src/icons/svg/tree-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/tree.svg b/admin/client/src/icons/svg/tree.svg new file mode 100644 index 0000000..dd4b7dd --- /dev/null +++ b/admin/client/src/icons/svg/tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/trophy.svg b/admin/client/src/icons/svg/trophy.svg new file mode 100644 index 0000000..5abd07c --- /dev/null +++ b/admin/client/src/icons/svg/trophy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/user.svg b/admin/client/src/icons/svg/user.svg new file mode 100644 index 0000000..0ba0716 --- /dev/null +++ b/admin/client/src/icons/svg/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/video.svg b/admin/client/src/icons/svg/video.svg new file mode 100644 index 0000000..a0fbcdb --- /dev/null +++ b/admin/client/src/icons/svg/video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/wechat.svg b/admin/client/src/icons/svg/wechat.svg new file mode 100644 index 0000000..c586e55 --- /dev/null +++ b/admin/client/src/icons/svg/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svg/zip.svg b/admin/client/src/icons/svg/zip.svg new file mode 100644 index 0000000..f806fc4 --- /dev/null +++ b/admin/client/src/icons/svg/zip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/client/src/icons/svgo.yml b/admin/client/src/icons/svgo.yml new file mode 100644 index 0000000..d11906a --- /dev/null +++ b/admin/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/admin/client/src/layout/components/AppMain.vue b/admin/client/src/layout/components/AppMain.vue new file mode 100644 index 0000000..f6a3286 --- /dev/null +++ b/admin/client/src/layout/components/AppMain.vue @@ -0,0 +1,40 @@ + + + + + + + diff --git a/admin/client/src/layout/components/Navbar.vue b/admin/client/src/layout/components/Navbar.vue new file mode 100644 index 0000000..2534892 --- /dev/null +++ b/admin/client/src/layout/components/Navbar.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/admin/client/src/layout/components/Sidebar/FixiOSBug.js b/admin/client/src/layout/components/Sidebar/FixiOSBug.js new file mode 100644 index 0000000..bc14856 --- /dev/null +++ b/admin/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/admin/client/src/layout/components/Sidebar/Item.vue b/admin/client/src/layout/components/Sidebar/Item.vue new file mode 100644 index 0000000..b515f61 --- /dev/null +++ b/admin/client/src/layout/components/Sidebar/Item.vue @@ -0,0 +1,29 @@ + diff --git a/admin/client/src/layout/components/Sidebar/Link.vue b/admin/client/src/layout/components/Sidebar/Link.vue new file mode 100644 index 0000000..eb4dd10 --- /dev/null +++ b/admin/client/src/layout/components/Sidebar/Link.vue @@ -0,0 +1,36 @@ + + + + diff --git a/admin/client/src/layout/components/Sidebar/Logo.vue b/admin/client/src/layout/components/Sidebar/Logo.vue new file mode 100644 index 0000000..fc7e40a --- /dev/null +++ b/admin/client/src/layout/components/Sidebar/Logo.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/admin/client/src/layout/components/Sidebar/SidebarItem.vue b/admin/client/src/layout/components/Sidebar/SidebarItem.vue new file mode 100644 index 0000000..a418c3d --- /dev/null +++ b/admin/client/src/layout/components/Sidebar/SidebarItem.vue @@ -0,0 +1,95 @@ + + + diff --git a/admin/client/src/layout/components/Sidebar/index.vue b/admin/client/src/layout/components/Sidebar/index.vue new file mode 100644 index 0000000..fb014a2 --- /dev/null +++ b/admin/client/src/layout/components/Sidebar/index.vue @@ -0,0 +1,54 @@ + + + diff --git a/admin/client/src/layout/components/index.js b/admin/client/src/layout/components/index.js new file mode 100644 index 0000000..97ee3cd --- /dev/null +++ b/admin/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/admin/client/src/layout/index.vue b/admin/client/src/layout/index.vue new file mode 100644 index 0000000..db22a7b --- /dev/null +++ b/admin/client/src/layout/index.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/admin/client/src/layout/mixin/ResizeHandler.js b/admin/client/src/layout/mixin/ResizeHandler.js new file mode 100644 index 0000000..e8d0df8 --- /dev/null +++ b/admin/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/admin/client/src/main.js b/admin/client/src/main.js new file mode 100644 index 0000000..701c67a --- /dev/null +++ b/admin/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/admin/client/src/permission.js b/admin/client/src/permission.js new file mode 100644 index 0000000..d4a08b6 --- /dev/null +++ b/admin/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/admin/client/src/router/index.js b/admin/client/src/router/index.js new file mode 100644 index 0000000..d3f2682 --- /dev/null +++ b/admin/client/src/router/index.js @@ -0,0 +1,269 @@ +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: '/crm', + component: Layout, + redirect: '/crm/projects', + name: 'CRM', + meta: { title: '小程序管理', icon: 'wechat' }, + children: [ + { + path: 'projects', + name: 'Projects', + component: () => import('@/views/crm/index'), // 这里的组件只是为了渲染 router-view + redirect: '/crm/projects/training', + meta: { title: '项目管理', icon: 'list' }, + children: [ + { + path: 'training', + name: 'ProjectsTraining', + component: () => import('@/views/crm/project'), + meta: { title: '学位项目', icon: 'education', type: 'training' } + }, + { + path: 'competition', + name: 'ProjectsCompetition', + component: () => import('@/views/crm/project'), + meta: { title: '典礼&论坛', icon: 'trophy', type: 'competition' } + }, + { + path: 'grading', + name: 'ProjectsGrading', + component: () => import('@/views/crm/project'), + meta: { title: '校友活动', icon: 'star', type: 'grading' } + } + ] + }, + { + path: 'teachers', + name: 'Teachers', + component: () => import('@/views/crm/teacher'), + meta: { title: '教学中心', icon: 'user' } + }, + { + path: 'coupons', + name: 'Coupons', + component: () => import('@/views/crm/coupon'), + meta: { title: '优惠券管理', icon: 'money' } + }, + { + path: 'issued-coupons', + name: 'IssuedCoupons', + component: () => import('@/views/crm/issued_coupon'), + meta: { title: '已发优惠券', icon: 'list' } + }, + { + path: 'banners', + name: 'Banners', + component: () => import('@/views/crm/banner'), + meta: { title: '轮播图管理', icon: 'drag' } + }, + { + path: 'students', + name: 'Students', + component: () => import('@/views/crm/student'), + meta: { title: '学员管理', icon: 'peoples' } + }, + { + path: 'honors', + name: 'Honors', + component: () => import('@/views/crm/honor'), + meta: { title: '学员荣誉管理', icon: 'medal' } + }, + { + path: 'showcases', + name: 'Showcases', + component: () => import('@/views/crm/showcase'), + meta: { title: '精彩视频', icon: 'video' } + } + ] + }, + { + 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: '/monitor', + component: Layout, + redirect: '/monitor/service', + name: 'Monitor', + meta: { title: '系统监控', icon: 'example', perms: ['monitor_set'] }, + children: [ + { + path: 'service', + name: 'service', + component: () => import('@/views/monitor/service'), + meta: { title: '服务监控', icon: 'example', perms: ['service_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'] } + } + ] + } + ] + }, + + // 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/admin/client/src/settings.js b/admin/client/src/settings.js new file mode 100644 index 0000000..585109b --- /dev/null +++ b/admin/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/admin/client/src/store/getters.js b/admin/client/src/store/getters.js new file mode 100644 index 0000000..1854b88 --- /dev/null +++ b/admin/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/admin/client/src/store/index.js b/admin/client/src/store/index.js new file mode 100644 index 0000000..6ae5dad --- /dev/null +++ b/admin/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/admin/client/src/store/modules/app.js b/admin/client/src/store/modules/app.js new file mode 100644 index 0000000..7ea7e33 --- /dev/null +++ b/admin/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/admin/client/src/store/modules/permission.js b/admin/client/src/store/modules/permission.js new file mode 100644 index 0000000..1ae691e --- /dev/null +++ b/admin/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/admin/client/src/store/modules/settings.js b/admin/client/src/store/modules/settings.js new file mode 100644 index 0000000..aab31a2 --- /dev/null +++ b/admin/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/admin/client/src/store/modules/user.js b/admin/client/src/store/modules/user.js new file mode 100644 index 0000000..3dba2c2 --- /dev/null +++ b/admin/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/admin/client/src/styles/element-ui.scss b/admin/client/src/styles/element-ui.scss new file mode 100644 index 0000000..bf345ec --- /dev/null +++ b/admin/client/src/styles/element-ui.scss @@ -0,0 +1,86 @@ +// 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; +} + +// Modern Button Styles +.el-button { + border-radius: 4px; + font-weight: 500; + + &--primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08); + transition: all 0.15s ease; + + &:hover, &:focus { + transform: translateY(-1px); + box-shadow: 0 7px 14px rgba(50, 50, 93, 0.1), 0 3px 6px rgba(0, 0, 0, 0.08); + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + } + + &:active { + transform: translateY(1px); + } + } + + &--success { + background: linear-gradient(135deg, #23bdb9 0%, #43e97b 100%); + border: none; + } + + &--danger { + background: linear-gradient(135deg, #ff5858 0%, #f09819 100%); + border: none; + } +} + +.el-tag { + border-radius: 4px; +} diff --git a/admin/client/src/styles/index.scss b/admin/client/src/styles/index.scss new file mode 100644 index 0000000..7da30f9 --- /dev/null +++ b/admin/client/src/styles/index.scss @@ -0,0 +1,143 @@ +@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: "Inter", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif; + background-color: #f0f2f5; +} + +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: 20px; + background-color: #f0f2f5; + min-height: calc(100vh - 50px); +} + +.el-table--medium td, .el-table--medium th { + padding: 8px 0; +} +.el-form-item { + margin-bottom: 22px; +} +.el-card { + border-radius: 8px; + border: none; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.1); + } +} +.el-message { + border-radius: 4px; + overflow: hidden; +} +.el-card__body { + padding: 20px; +} +.el-card__header { + padding: 18px 20px; + border-bottom: 1px solid #ebeef5; + font-weight: bold; + font-size: 16px; +} +.el-tabs--border-card>.el-tabs__content { + padding: 15px; +} +.el-dialog__header { + padding: 20px 20px 10px; + font-weight: bold; +} + .el-dialog{ + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + 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: 20px 24px; +} + +.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/admin/client/src/styles/mixin.scss b/admin/client/src/styles/mixin.scss new file mode 100644 index 0000000..36b74bb --- /dev/null +++ b/admin/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/admin/client/src/styles/sidebar.scss b/admin/client/src/styles/sidebar.scss new file mode 100644 index 0000000..3dad4c3 --- /dev/null +++ b/admin/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/admin/client/src/styles/transition.scss b/admin/client/src/styles/transition.scss new file mode 100644 index 0000000..4cb27cc --- /dev/null +++ b/admin/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/admin/client/src/styles/variables.scss b/admin/client/src/styles/variables.scss new file mode 100644 index 0000000..1adb372 --- /dev/null +++ b/admin/client/src/styles/variables.scss @@ -0,0 +1,25 @@ +// sidebar +$menuText:#bfcbd9; +$menuActiveText:#409EFF; +$subMenuActiveText:#f4f4f5; //https://github.com/ElemeFE/element/issues/12951 + +$menuBg:#2b333e; +$menuHover:#1f262f; + +$subMenuBg:#1f262f; +$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/admin/client/src/utils/auth.js b/admin/client/src/utils/auth.js new file mode 100644 index 0000000..392db62 --- /dev/null +++ b/admin/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/admin/client/src/utils/get-page-title.js b/admin/client/src/utils/get-page-title.js new file mode 100644 index 0000000..cfe5800 --- /dev/null +++ b/admin/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/admin/client/src/utils/index.js b/admin/client/src/utils/index.js new file mode 100644 index 0000000..722c202 --- /dev/null +++ b/admin/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/admin/client/src/utils/permission.js b/admin/client/src/utils/permission.js new file mode 100644 index 0000000..217bdeb --- /dev/null +++ b/admin/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/admin/client/src/utils/request.js b/admin/client/src/utils/request.js new file mode 100644 index 0000000..da72b1a --- /dev/null +++ b/admin/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/admin/client/src/utils/scroll-to.js b/admin/client/src/utils/scroll-to.js new file mode 100644 index 0000000..c5d8e04 --- /dev/null +++ b/admin/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/admin/client/src/utils/validate.js b/admin/client/src/utils/validate.js new file mode 100644 index 0000000..8d962ad --- /dev/null +++ b/admin/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/admin/client/src/vendor/Export2Excel.js b/admin/client/src/vendor/Export2Excel.js new file mode 100644 index 0000000..d8a2af3 --- /dev/null +++ b/admin/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/admin/client/src/vendor/Export2Zip.js b/admin/client/src/vendor/Export2Zip.js new file mode 100644 index 0000000..db70707 --- /dev/null +++ b/admin/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/admin/client/src/views/404.vue b/admin/client/src/views/404.vue new file mode 100644 index 0000000..18eda34 --- /dev/null +++ b/admin/client/src/views/404.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/admin/client/src/views/crm/banner.vue b/admin/client/src/views/crm/banner.vue new file mode 100644 index 0000000..ca796a9 --- /dev/null +++ b/admin/client/src/views/crm/banner.vue @@ -0,0 +1,242 @@ + + + + + \ No newline at end of file diff --git a/admin/client/src/views/crm/category.vue b/admin/client/src/views/crm/category.vue new file mode 100644 index 0000000..b846e51 --- /dev/null +++ b/admin/client/src/views/crm/category.vue @@ -0,0 +1,174 @@ + + + diff --git a/admin/client/src/views/crm/coupon.vue b/admin/client/src/views/crm/coupon.vue new file mode 100644 index 0000000..9abca0f --- /dev/null +++ b/admin/client/src/views/crm/coupon.vue @@ -0,0 +1,297 @@ + + + diff --git a/admin/client/src/views/crm/honor.vue b/admin/client/src/views/crm/honor.vue new file mode 100644 index 0000000..fcc9903 --- /dev/null +++ b/admin/client/src/views/crm/honor.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/admin/client/src/views/crm/index.vue b/admin/client/src/views/crm/index.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/admin/client/src/views/crm/index.vue @@ -0,0 +1,3 @@ + diff --git a/admin/client/src/views/crm/issued_coupon.vue b/admin/client/src/views/crm/issued_coupon.vue new file mode 100644 index 0000000..a2a0b29 --- /dev/null +++ b/admin/client/src/views/crm/issued_coupon.vue @@ -0,0 +1,175 @@ + + + diff --git a/admin/client/src/views/crm/project.vue b/admin/client/src/views/crm/project.vue new file mode 100644 index 0000000..d5eda57 --- /dev/null +++ b/admin/client/src/views/crm/project.vue @@ -0,0 +1,542 @@ + + + + + \ No newline at end of file diff --git a/admin/client/src/views/crm/showcase.vue b/admin/client/src/views/crm/showcase.vue new file mode 100644 index 0000000..41a255a --- /dev/null +++ b/admin/client/src/views/crm/showcase.vue @@ -0,0 +1,304 @@ + + + diff --git a/admin/client/src/views/crm/student.vue b/admin/client/src/views/crm/student.vue new file mode 100644 index 0000000..cedb182 --- /dev/null +++ b/admin/client/src/views/crm/student.vue @@ -0,0 +1,434 @@ + + + diff --git a/admin/client/src/views/crm/teacher.vue b/admin/client/src/views/crm/teacher.vue new file mode 100644 index 0000000..03937ad --- /dev/null +++ b/admin/client/src/views/crm/teacher.vue @@ -0,0 +1,170 @@ + + + diff --git a/admin/client/src/views/dashboard/components/BarChart.vue b/admin/client/src/views/dashboard/components/BarChart.vue new file mode 100644 index 0000000..e4f859c --- /dev/null +++ b/admin/client/src/views/dashboard/components/BarChart.vue @@ -0,0 +1,94 @@ + + + diff --git a/admin/client/src/views/dashboard/components/LineChart.vue b/admin/client/src/views/dashboard/components/LineChart.vue new file mode 100644 index 0000000..bd7069c --- /dev/null +++ b/admin/client/src/views/dashboard/components/LineChart.vue @@ -0,0 +1,135 @@ + + + diff --git a/admin/client/src/views/dashboard/components/PanelGroup.vue b/admin/client/src/views/dashboard/components/PanelGroup.vue new file mode 100644 index 0000000..1cf6ebb --- /dev/null +++ b/admin/client/src/views/dashboard/components/PanelGroup.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/admin/client/src/views/dashboard/components/PieChart.vue b/admin/client/src/views/dashboard/components/PieChart.vue new file mode 100644 index 0000000..e701dd1 --- /dev/null +++ b/admin/client/src/views/dashboard/components/PieChart.vue @@ -0,0 +1,85 @@ + + + diff --git a/admin/client/src/views/dashboard/index.vue b/admin/client/src/views/dashboard/index.vue new file mode 100644 index 0000000..2eea36a --- /dev/null +++ b/admin/client/src/views/dashboard/index.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/admin/client/src/views/dashboard/mixins/resize.js b/admin/client/src/views/dashboard/mixins/resize.js new file mode 100644 index 0000000..9c559db --- /dev/null +++ b/admin/client/src/views/dashboard/mixins/resize.js @@ -0,0 +1,43 @@ +import { debounce } from '@/utils' + +export default { + data() { + return { + $_sidebarElm: null, + $_resizeHandler: null + } + }, + mounted() { + this.$_resizeHandler = debounce(() => { + if (this.chart) { + this.chart.resize() + } + }, 100) + this.$_initResizeEvent() + this.$_sidebarElm = document.getElementsByClassName('sidebar-container')[0] + if (this.$_sidebarElm) { + this.$_sidebarElm.addEventListener('transitionend', this.$_sidebarResizeHandler) + } + }, + beforeDestroy() { + this.$_destroyResizeEvent() + if (this.$_sidebarElm) { + this.$_sidebarElm.removeEventListener('transitionend', this.$_sidebarResizeHandler) + } + }, + methods: { + // use $_ for mixins properties + // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential + $_initResizeEvent() { + window.addEventListener('resize', this.$_resizeHandler) + }, + $_destroyResizeEvent() { + window.removeEventListener('resize', this.$_resizeHandler) + }, + $_sidebarResizeHandler(e) { + if (e.propertyName === 'width') { + this.$_resizeHandler() + } + } + } +} diff --git a/admin/client/src/views/form/index.vue b/admin/client/src/views/form/index.vue new file mode 100644 index 0000000..f4d66d3 --- /dev/null +++ b/admin/client/src/views/form/index.vue @@ -0,0 +1,85 @@ + + + + + + diff --git a/admin/client/src/views/login/index.vue b/admin/client/src/views/login/index.vue new file mode 100644 index 0000000..ec4b349 --- /dev/null +++ b/admin/client/src/views/login/index.vue @@ -0,0 +1,246 @@ + + + + + + + diff --git a/admin/client/src/views/monitor/service.vue b/admin/client/src/views/monitor/service.vue new file mode 100644 index 0000000..b9473ff --- /dev/null +++ b/admin/client/src/views/monitor/service.vue @@ -0,0 +1,177 @@ + + + + \ No newline at end of file diff --git a/admin/client/src/views/nested/menu1/index.vue b/admin/client/src/views/nested/menu1/index.vue new file mode 100644 index 0000000..30cb670 --- /dev/null +++ b/admin/client/src/views/nested/menu1/index.vue @@ -0,0 +1,7 @@ + diff --git a/admin/client/src/views/nested/menu1/menu1-1/index.vue b/admin/client/src/views/nested/menu1/menu1-1/index.vue new file mode 100644 index 0000000..27e173a --- /dev/null +++ b/admin/client/src/views/nested/menu1/menu1-1/index.vue @@ -0,0 +1,7 @@ + diff --git a/admin/client/src/views/nested/menu1/menu1-2/index.vue b/admin/client/src/views/nested/menu1/menu1-2/index.vue new file mode 100644 index 0000000..0c86276 --- /dev/null +++ b/admin/client/src/views/nested/menu1/menu1-2/index.vue @@ -0,0 +1,7 @@ + diff --git a/admin/client/src/views/nested/menu1/menu1-2/menu1-2-1/index.vue b/admin/client/src/views/nested/menu1/menu1-2/menu1-2-1/index.vue new file mode 100644 index 0000000..f87d88f --- /dev/null +++ b/admin/client/src/views/nested/menu1/menu1-2/menu1-2-1/index.vue @@ -0,0 +1,5 @@ + diff --git a/admin/client/src/views/nested/menu1/menu1-2/menu1-2-2/index.vue b/admin/client/src/views/nested/menu1/menu1-2/menu1-2-2/index.vue new file mode 100644 index 0000000..d88789f --- /dev/null +++ b/admin/client/src/views/nested/menu1/menu1-2/menu1-2-2/index.vue @@ -0,0 +1,5 @@ + diff --git a/admin/client/src/views/nested/menu1/menu1-3/index.vue b/admin/client/src/views/nested/menu1/menu1-3/index.vue new file mode 100644 index 0000000..f7cd073 --- /dev/null +++ b/admin/client/src/views/nested/menu1/menu1-3/index.vue @@ -0,0 +1,5 @@ + diff --git a/admin/client/src/views/nested/menu2/index.vue b/admin/client/src/views/nested/menu2/index.vue new file mode 100644 index 0000000..19dd48f --- /dev/null +++ b/admin/client/src/views/nested/menu2/index.vue @@ -0,0 +1,5 @@ + diff --git a/admin/client/src/views/system/changepassword.vue b/admin/client/src/views/system/changepassword.vue new file mode 100644 index 0000000..165c218 --- /dev/null +++ b/admin/client/src/views/system/changepassword.vue @@ -0,0 +1,78 @@ + + \ No newline at end of file diff --git a/admin/client/src/views/system/dict.vue b/admin/client/src/views/system/dict.vue new file mode 100644 index 0000000..988feab --- /dev/null +++ b/admin/client/src/views/system/dict.vue @@ -0,0 +1,370 @@ + + + diff --git a/admin/client/src/views/system/file.vue b/admin/client/src/views/system/file.vue new file mode 100644 index 0000000..0819962 --- /dev/null +++ b/admin/client/src/views/system/file.vue @@ -0,0 +1,181 @@ + + diff --git a/admin/client/src/views/system/organization.vue b/admin/client/src/views/system/organization.vue new file mode 100644 index 0000000..a71f2b6 --- /dev/null +++ b/admin/client/src/views/system/organization.vue @@ -0,0 +1,225 @@ + + + diff --git a/admin/client/src/views/system/perm.vue b/admin/client/src/views/system/perm.vue new file mode 100644 index 0000000..ae8a7cf --- /dev/null +++ b/admin/client/src/views/system/perm.vue @@ -0,0 +1,239 @@ + + + diff --git a/admin/client/src/views/system/position.vue b/admin/client/src/views/system/position.vue new file mode 100644 index 0000000..cffc146 --- /dev/null +++ b/admin/client/src/views/system/position.vue @@ -0,0 +1,211 @@ + + + diff --git a/admin/client/src/views/system/role.vue b/admin/client/src/views/system/role.vue new file mode 100644 index 0000000..a19ebd0 --- /dev/null +++ b/admin/client/src/views/system/role.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/admin/client/src/views/system/task.vue b/admin/client/src/views/system/task.vue new file mode 100644 index 0000000..62797e9 --- /dev/null +++ b/admin/client/src/views/system/task.vue @@ -0,0 +1,410 @@ + + diff --git a/admin/client/src/views/system/user.vue b/admin/client/src/views/system/user.vue new file mode 100644 index 0000000..6e33e4d --- /dev/null +++ b/admin/client/src/views/system/user.vue @@ -0,0 +1,378 @@ + + + diff --git a/admin/client/src/views/table/index.vue b/admin/client/src/views/table/index.vue new file mode 100644 index 0000000..a1ed847 --- /dev/null +++ b/admin/client/src/views/table/index.vue @@ -0,0 +1,79 @@ + + + diff --git a/admin/client/src/views/tree/index.vue b/admin/client/src/views/tree/index.vue new file mode 100644 index 0000000..89c6b01 --- /dev/null +++ b/admin/client/src/views/tree/index.vue @@ -0,0 +1,78 @@ + + + + diff --git a/admin/client/src/views/workflow/configuration.vue b/admin/client/src/views/workflow/configuration.vue new file mode 100644 index 0000000..aaee8bc --- /dev/null +++ b/admin/client/src/views/workflow/configuration.vue @@ -0,0 +1,52 @@ + + diff --git a/admin/client/src/views/workflow/customfield.vue b/admin/client/src/views/workflow/customfield.vue new file mode 100644 index 0000000..8013b84 --- /dev/null +++ b/admin/client/src/views/workflow/customfield.vue @@ -0,0 +1,447 @@ + + diff --git a/admin/client/src/views/workflow/index.vue b/admin/client/src/views/workflow/index.vue new file mode 100644 index 0000000..1694c63 --- /dev/null +++ b/admin/client/src/views/workflow/index.vue @@ -0,0 +1,515 @@ + + + + diff --git a/admin/client/src/views/workflow/state.vue b/admin/client/src/views/workflow/state.vue new file mode 100644 index 0000000..237267a --- /dev/null +++ b/admin/client/src/views/workflow/state.vue @@ -0,0 +1,487 @@ + + diff --git a/admin/client/src/views/workflow/test.vue b/admin/client/src/views/workflow/test.vue new file mode 100644 index 0000000..fa06867 --- /dev/null +++ b/admin/client/src/views/workflow/test.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/admin/client/src/views/workflow/ticket.vue b/admin/client/src/views/workflow/ticket.vue new file mode 100644 index 0000000..14971fd --- /dev/null +++ b/admin/client/src/views/workflow/ticket.vue @@ -0,0 +1,1020 @@ + + + + + diff --git a/admin/client/src/views/workflow/ticketDetail.vue b/admin/client/src/views/workflow/ticketDetail.vue new file mode 100644 index 0000000..0b5851d --- /dev/null +++ b/admin/client/src/views/workflow/ticketDetail.vue @@ -0,0 +1,563 @@ + + + + diff --git a/admin/client/src/views/workflow/ticketHandle.vue b/admin/client/src/views/workflow/ticketHandle.vue new file mode 100644 index 0000000..79c92aa --- /dev/null +++ b/admin/client/src/views/workflow/ticketHandle.vue @@ -0,0 +1,816 @@ + + + + diff --git a/admin/client/src/views/workflow/transitions.vue b/admin/client/src/views/workflow/transitions.vue new file mode 100644 index 0000000..d0827f6 --- /dev/null +++ b/admin/client/src/views/workflow/transitions.vue @@ -0,0 +1,267 @@ + + diff --git a/admin/client/src/views/workflow/workFlowTickets.vue b/admin/client/src/views/workflow/workFlowTickets.vue new file mode 100644 index 0000000..84a0ea4 --- /dev/null +++ b/admin/client/src/views/workflow/workFlowTickets.vue @@ -0,0 +1,197 @@ + + + + diff --git a/admin/client/tests/unit/.eslintrc.js b/admin/client/tests/unit/.eslintrc.js new file mode 100644 index 0000000..958d51b --- /dev/null +++ b/admin/client/tests/unit/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + env: { + jest: true + } +} diff --git a/admin/client/tests/unit/components/Breadcrumb.spec.js b/admin/client/tests/unit/components/Breadcrumb.spec.js new file mode 100644 index 0000000..1d94c8f --- /dev/null +++ b/admin/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/admin/client/tests/unit/components/Hamburger.spec.js b/admin/client/tests/unit/components/Hamburger.spec.js new file mode 100644 index 0000000..01ea303 --- /dev/null +++ b/admin/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/admin/client/tests/unit/components/SvgIcon.spec.js b/admin/client/tests/unit/components/SvgIcon.spec.js new file mode 100644 index 0000000..31467a9 --- /dev/null +++ b/admin/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/admin/client/tests/unit/utils/formatTime.spec.js b/admin/client/tests/unit/utils/formatTime.spec.js new file mode 100644 index 0000000..24e165b --- /dev/null +++ b/admin/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/admin/client/tests/unit/utils/parseTime.spec.js b/admin/client/tests/unit/utils/parseTime.spec.js new file mode 100644 index 0000000..41d1b02 --- /dev/null +++ b/admin/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/admin/client/tests/unit/utils/validate.spec.js b/admin/client/tests/unit/utils/validate.spec.js new file mode 100644 index 0000000..f774905 --- /dev/null +++ b/admin/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/admin/client/vue.config.js b/admin/client/vue.config.js new file mode 100644 index 0000000..ae2b413 --- /dev/null +++ b/admin/client/vue.config.js @@ -0,0 +1,137 @@ +'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: '../server/dist', + assetsDir: 'static', + lintOnSave: false, //process.env.NODE_ENV === 'development', + productionSourceMap: false, + devServer: { + host: '0.0.0.0', + disableHostCheck: true, + port: port, + open: true, + overlay: { + warnings: false, + errors: true + }, + proxy: { + '/api': { + // target: 'http://localhost:8000', + target: process.env.PROXY_TARGET || 'http://127.0.0.1:8000', + changeOrigin: 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/admin/docker-compose.yml b/admin/docker-compose.yml new file mode 100644 index 0000000..9373f75 --- /dev/null +++ b/admin/docker-compose.yml @@ -0,0 +1,27 @@ +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" + environment: + - PROXY_TARGET=http://backend:80 + redis: + image: redis + command: redis-server --appendonly yes \ No newline at end of file diff --git a/admin/img/dict.png b/admin/img/dict.png new file mode 100644 index 0000000..3942331 Binary files /dev/null and b/admin/img/dict.png differ diff --git a/admin/img/docs.png b/admin/img/docs.png new file mode 100644 index 0000000..a081540 Binary files /dev/null and b/admin/img/docs.png differ diff --git a/admin/img/task.png b/admin/img/task.png new file mode 100644 index 0000000..0014fd6 Binary files /dev/null and b/admin/img/task.png differ diff --git a/admin/img/ticket.png b/admin/img/ticket.png new file mode 100644 index 0000000..7b663b2 Binary files /dev/null and b/admin/img/ticket.png differ diff --git a/admin/img/user.png b/admin/img/user.png new file mode 100644 index 0000000..aa6526a Binary files /dev/null and b/admin/img/user.png differ diff --git a/admin/server/.gitignore b/admin/server/.gitignore new file mode 100644 index 0000000..81088be --- /dev/null +++ b/admin/server/.gitignore @@ -0,0 +1,15 @@ +.vscode/ +.vs/ +venv/ +__pycache__/ +*.pyc +media/* +vuedist/* +dist/* +!media/default/ +celerybeat.pid +celerybeat-schedule.bak +celerybeat-schedule.dat +celerybeat-schedule.dir +db.sqlite3 +server/conf.py \ No newline at end of file diff --git a/admin/server/Dockerfile b/admin/server/Dockerfile new file mode 100644 index 0000000..d35db3b --- /dev/null +++ b/admin/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/admin/server/apps/crm/__init__.py b/admin/server/apps/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/crm/admin.py b/admin/server/apps/crm/admin.py new file mode 100644 index 0000000..631c887 --- /dev/null +++ b/admin/server/apps/crm/admin.py @@ -0,0 +1,76 @@ +from django.contrib import admin +from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentHonor, StudentShowcase + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'color') + search_fields = ('name',) + +@admin.register(Teacher) +class TeacherAdmin(admin.ModelAdmin): + list_display = ('name', 'phone') + search_fields = ('name', 'phone') + +@admin.register(TeachingCenter) +class TeachingCenterAdmin(admin.ModelAdmin): + list_display = ('name', 'address', 'phone') + search_fields = ('name', 'phone') + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + # list_display = ('title', 'category', 'students', 'rating', 'created_at') + list_display = ('title', 'teacher', 'students', 'address', 'created_at') + # list_filter = ('category', 'created_at') + list_filter = ('teacher', 'created_at') + search_fields = ('title',) + +@admin.register(Coupon) +class CouponAdmin(admin.ModelAdmin): + list_display = ('title', 'amount', 'unit', 'expiry', 'status', 'issue_type') + list_filter = ('status', 'expiry', 'issue_type') + search_fields = ('title',) + filter_horizontal = ('applicable_projects',) + +@admin.register(Banner) +class BannerAdmin(admin.ModelAdmin): + list_display = ('id', 'image', 'link', 'sort_order', 'is_active') + list_editable = ('sort_order', 'is_active') + +@admin.register(Student) +class StudentAdmin(admin.ModelAdmin): + list_display = ('name', 'phone', 'wechat_nickname', 'openid', 'teaching_center', 'company_name', 'status', 'learning_count', 'created_at') + search_fields = ('name', 'phone', 'wechat_nickname', 'openid', 'company_name') + list_filter = ('teaching_center', 'status') + inlines = [] + +class StudentCouponInline(admin.TabularInline): + model = StudentCoupon + extra = 1 + +StudentAdmin.inlines = [StudentCouponInline] + +@admin.register(StudentCoupon) +class StudentCouponAdmin(admin.ModelAdmin): + list_display = ('student', 'coupon', 'status', 'assigned_at') + list_filter = ('status',) + search_fields = ('student__name', 'coupon__title') + +@admin.register(StudentHonor) +class StudentHonorAdmin(admin.ModelAdmin): + list_display = ('student', 'get_student_center', 'get_student_company', 'title', 'date', 'created_at') + search_fields = ('student__name', 'title', 'student__company_name') + list_filter = ('date',) + + def get_student_center(self, obj): + return obj.student.teaching_center + get_student_center.short_description = '教学中心' + + def get_student_company(self, obj): + return obj.student.company_name + get_student_company.short_description = '公司名称' + +@admin.register(StudentShowcase) +class StudentShowcaseAdmin(admin.ModelAdmin): + list_display = ('title', 'student', 'sort_order', 'is_active', 'created_at') + list_editable = ('sort_order', 'is_active') + search_fields = ('title', 'student__name') diff --git a/admin/server/apps/crm/apps.py b/admin/server/apps/crm/apps.py new file mode 100644 index 0000000..d721574 --- /dev/null +++ b/admin/server/apps/crm/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CrmConfig(AppConfig): + name = 'apps.crm' + verbose_name = 'CRM客户管理' diff --git a/admin/server/apps/crm/authentication.py b/admin/server/apps/crm/authentication.py new file mode 100644 index 0000000..ed09edb --- /dev/null +++ b/admin/server/apps/crm/authentication.py @@ -0,0 +1,49 @@ +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +from .models import Student + +class MiniProgramUser: + """ + A proxy user class to represent an authenticated Mini Program student + """ + def __init__(self, student): + self.student = student + self.username = student.name or 'WeChat User' + self.is_authenticated = True + self.is_staff = False + self.is_superuser = False + + def __str__(self): + return self.username + +class MiniProgramAuthentication(BaseAuthentication): + """ + Custom Authentication for WeChat Mini Program + Parses 'Bearer mock_token_{id}' and sets request.user to a proxy user + """ + + def authenticate(self, request): + auth = request.headers.get('Authorization') + if not auth or 'Bearer' not in auth: + return None + + try: + token_str = auth.split(' ')[1] + if not token_str.startswith('mock_token_'): + return None # Let other authenticators (like JWT) handle it + + try: + student_id = int(token_str.split('_')[-1]) + student = Student.objects.get(id=student_id) + except (ValueError, Student.DoesNotExist): + raise AuthenticationFailed('Invalid mini-program token') + + # Create a proxy user + user = MiniProgramUser(student) + + return (user, token_str) + + except AuthenticationFailed: + raise + except Exception: + return None diff --git a/admin/server/apps/crm/management/__init__.py b/admin/server/apps/crm/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/crm/management/commands/__init__.py b/admin/server/apps/crm/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/crm/management/commands/init_crm_data.py b/admin/server/apps/crm/management/commands/init_crm_data.py new file mode 100644 index 0000000..1fe6e9b --- /dev/null +++ b/admin/server/apps/crm/management/commands/init_crm_data.py @@ -0,0 +1,104 @@ +from django.core.management.base import BaseCommand +from apps.crm.models import Category, Project, Coupon, Banner, Student +from django.utils import timezone +import random + +class Command(BaseCommand): + help = 'Initialize CRM mock data' + + def handle(self, *args, **options): + self.stdout.write('Cleaning up old data...') + Category.objects.all().delete() + Project.objects.all().delete() + Coupon.objects.all().delete() + Banner.objects.all().delete() + Student.objects.all().delete() + + self.stdout.write('Creating Categories...') + categories_data = [ + {'name': '前端开发', 'color': 'bg-blue-100 text-blue-600'}, + {'name': '后端开发', 'color': 'bg-green-100 text-green-600'}, + {'name': '人工智能', 'color': 'bg-purple-100 text-purple-600'}, + {'name': '移动开发', 'color': 'bg-yellow-100 text-yellow-600'}, + {'name': '运维测试', 'color': 'bg-red-100 text-red-600'}, + ] + categories = [] + for data in categories_data: + cat = Category.objects.create(**data) + categories.append(cat) + self.stdout.write(f'Created category: {cat.name}') + + self.stdout.write('Creating Projects...') + projects_data = [ + {'title': 'Vue3实战教程', 'detail': '深入学习Vue3核心原理与实战应用...'}, + {'title': 'Django REST Framework入门', 'detail': '从零构建RESTful API...'}, + {'title': 'Python数据分析', 'detail': '使用Pandas和NumPy进行数据分析...'}, + {'title': 'Flutter跨平台开发', 'detail': '构建高性能移动应用...'}, + {'title': 'Docker容器化部署', 'detail': '掌握Docker容器技术...'}, + {'title': 'React Hooks进阶', 'detail': '精通React Hooks...'}, + {'title': 'Go语言高并发编程', 'detail': 'Go语言核心特性与并发模式...'}, + {'title': '机器学习基础', 'detail': '常用机器学习算法原理与实现...'}, + {'title': 'iOS Swift开发', 'detail': 'Swift语言基础与iOS应用开发...'}, + {'title': 'Kubernetes集群管理', 'detail': 'K8s集群部署与维护...'}, + ] + + projects = [] + for i, data in enumerate(projects_data): + cat = categories[i % len(categories)] + proj = Project.objects.create( + title=data['title'], + category=cat, + image=f'https://picsum.photos/seed/{i}/800/600', + detail=data['detail'], + students=random.randint(100, 1000), + rating=round(random.uniform(4.0, 5.0), 1), + duration=f'{random.randint(2, 8)} 周' + ) + projects.append(proj) + self.stdout.write(f'Created project: {proj.title}') + + self.stdout.write('Creating Coupons...') + coupons_data = [ + {'amount': '50', 'title': '新人专享券', 'desc': '全场通用,无门槛'}, + {'amount': '100', 'title': '进阶课程券', 'desc': '满500可用'}, + {'amount': '200', 'title': '高级课程券', 'desc': '满1000可用'}, + {'amount': '8.8', 'title': '限时折扣', 'desc': '全场8.8折', 'unit': '折'}, + {'amount': '500', 'title': 'VIP专属', 'desc': 'VIP会员专用'}, + ] + + for data in coupons_data: + Coupon.objects.create( + amount=data['amount'], + unit=data.get('unit', '元'), + title=data['title'], + desc=data['desc'], + expiry=timezone.now().date() + timezone.timedelta(days=random.randint(7, 30)) + ) + self.stdout.write(f'Created coupon: {data["title"]}') + + self.stdout.write('Creating Students...') + for i in range(10): + Student.objects.create( + name=f'学员{i+1}', + phone=f'1380013800{i}', + age=random.randint(18, 35), + education=random.choice(['本科', '硕士', '大专']), + address=f'北京市海淀区某街道{i+1}号', + learning_count=random.randint(1, 5), + coupon_count=random.randint(0, 3), + study_hours=random.randint(10, 100) + ) + self.stdout.write(f'Created student: {i+1}') + + self.stdout.write('Creating Banners...') + for i in range(3): + proj = random.choice(projects) + Banner.objects.create( + image=f'https://picsum.photos/seed/banner{i}/1920/600', + project=proj, + sort_order=i, + is_active=True + ) + self.stdout.write(f'Created banner {i+1} linking to {proj.title}') + + self.stdout.write(self.style.SUCCESS('Successfully initialized mock data')) diff --git a/admin/server/apps/crm/management/commands/populate_degree_ceremony.py b/admin/server/apps/crm/management/commands/populate_degree_ceremony.py new file mode 100644 index 0000000..dbc67ba --- /dev/null +++ b/admin/server/apps/crm/management/commands/populate_degree_ceremony.py @@ -0,0 +1,108 @@ +import random +from django.core.management.base import BaseCommand +from apps.crm.models import Teacher, Project + +class Command(BaseCommand): + help = 'Populate database with mock data for In-service Degrees and Opening Ceremonies' + + def handle(self, *args, **kwargs): + self.stdout.write('Starting to populate degree and ceremony mock data...') + + # 1. Create Professors (Teachers) + self.stdout.write('Creating Professors...') + teachers_data = [ + {'name': '钱教授', 'phone': '13800138005', 'bio': '商学院资深教授,博士生导师,主讲企业战略管理。'}, + {'name': '孙教授', 'phone': '13800138006', 'bio': '心理学系主任,专注组织行为学研究。'}, + {'name': '周老师', 'phone': '13800138007', 'bio': '教务处主任,负责典礼与活动策划。'}, + ] + + teachers = [] + for data in teachers_data: + teacher, created = Teacher.objects.get_or_create(name=data['name'], defaults=data) + if created: + self.stdout.write(f'Created teacher: {teacher.name}') + else: + self.stdout.write(f'Teacher already exists: {teacher.name}') + teachers.append(teacher) + + # 2. Create Projects + self.stdout.write('Creating Projects...') + projects_data = [ + # In-service Degrees (training) + { + 'title': 'MBA工商管理硕士', + 'project_type': 'training', + 'teacher': teachers[0], + 'image': 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', + 'detail': '培养具有全球视野、战略思维和卓越领导力的商业领袖。课程涵盖管理经济学、组织行为学、市场营销、财务管理等核心模块。', + 'rating': 4.9, + 'duration': '2年', + 'price': 168000.00, + 'students': 120 + }, + { + 'title': 'EMBA高级工商管理硕士', + 'project_type': 'training', + 'teacher': teachers[0], + 'image': 'https://images.unsplash.com/photo-1556761175-5973dc0f32e7', + 'detail': '专为企业高层管理者设计,通过实战案例教学,提升决策能力和领导艺术。', + 'rating': 5.0, + 'duration': '2年', + 'price': 298000.00, + 'students': 45 + }, + { + 'title': '应用心理学在职研究生', + 'project_type': 'training', + 'teacher': teachers[1], + 'image': 'https://images.unsplash.com/photo-1571260899304-425eee4c7efc', + 'detail': '结合心理学理论与实践,帮助学员掌握心理咨询、EAP服务及人力资源管理中的心理学应用。', + 'rating': 4.8, + 'duration': '1.5年', + 'price': 48000.00, + 'students': 80 + }, + # Opening Ceremonies & Forums (competition) + { + 'title': '2025春季开学典礼', + 'project_type': 'competition', + 'teacher': teachers[2], + 'image': 'https://images.unsplash.com/photo-1523580494863-6f3031224c94', + 'detail': '欢迎2025级新同学加入大家庭,届时将有校长致辞、新生代表发言及入学宣誓环节。', + 'rating': 5.0, + 'duration': '半天', + 'price': 0.00, + 'students': 300 + }, + { + 'title': '2024创新创业高峰论坛', + 'project_type': 'competition', + 'teacher': teachers[0], + 'image': 'https://images.unsplash.com/photo-1475721027767-27a66263adeb', + 'detail': '汇聚行业精英,探讨数字化时代的商业创新机遇与挑战。', + 'rating': 4.9, + 'duration': '1天', + 'price': 1280.00, + 'students': 200 + }, + { + 'title': '2024秋季毕业典礼', + 'project_type': 'competition', + 'teacher': teachers[2], + 'image': 'https://images.unsplash.com/photo-1627556704290-2b1f5853ff78', + 'detail': '见证荣耀时刻,开启人生新篇章。', + 'rating': 5.0, + 'duration': '半天', + 'price': 0.00, + 'students': 150 + } + ] + + for data in projects_data: + proj, created = Project.objects.get_or_create(title=data['title'], defaults=data) + if created: + self.stdout.write(f'Created project: {proj.title}') + else: + self.stdout.write(f'Project already exists: {proj.title}') + + self.stdout.write(self.style.SUCCESS('Successfully populated degree and ceremony mock data!')) diff --git a/admin/server/apps/crm/management/commands/populate_mock_data.py b/admin/server/apps/crm/management/commands/populate_mock_data.py new file mode 100644 index 0000000..5f3077d --- /dev/null +++ b/admin/server/apps/crm/management/commands/populate_mock_data.py @@ -0,0 +1,248 @@ +import random +from datetime import timedelta +from django.core.management.base import BaseCommand +from django.utils import timezone +from apps.crm.models import Category, Teacher, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor + +class Command(BaseCommand): + help = 'Populate database with mock data for demonstration' + + def handle(self, *args, **kwargs): + self.stdout.write('Starting to populate mock data...') + + # 1. Create Categories + self.stdout.write('Creating Categories...') + categories_data = [ + {'name': '少儿编程', 'color': 'bg-blue-100 text-blue-600'}, + {'name': '美术创意', 'color': 'bg-pink-100 text-pink-600'}, + {'name': '口才演讲', 'color': 'bg-purple-100 text-purple-600'}, + {'name': '科学实验', 'color': 'bg-green-100 text-green-600'}, + {'name': '围棋象棋', 'color': 'bg-yellow-100 text-yellow-600'}, + ] + + categories = [] + for data in categories_data: + cat, created = Category.objects.get_or_create(name=data['name'], defaults=data) + categories.append(cat) + + # 2. Create Teachers + self.stdout.write('Creating Teachers...') + teachers_data = [ + {'name': '李老师', 'phone': '13800138001', 'bio': '资深少儿编程讲师,拥有5年教学经验。'}, + {'name': '王老师', 'phone': '13800138002', 'bio': '美术学院毕业,擅长创意美术教学。'}, + {'name': '张老师', 'phone': '13800138003', 'bio': '播音主持专业,专注于少儿口才培养。'}, + {'name': '赵老师', 'phone': '13800138004', 'bio': '科学教育专家,带队多次获得科技大赛奖项。'}, + ] + + teachers = [] + for data in teachers_data: + teacher, created = Teacher.objects.get_or_create(name=data['name'], defaults=data) + teachers.append(teacher) + + # 3. Create Projects + self.stdout.write('Creating Projects...') + projects_data = [ + { + 'title': 'Python趣味编程入门', + 'project_type': 'training', + 'teacher': teachers[0], + 'image': 'https://images.unsplash.com/photo-1526379095098-d400fd0bf935', + 'detail': '通过趣味游戏学习Python编程基础,培养逻辑思维能力。', + 'rating': 4.9, + 'duration': '12 课时' + }, + { + 'title': '创意色彩大师班', + 'project_type': 'training', + 'teacher': teachers[1], + 'image': 'https://images.unsplash.com/photo-1513364776144-60967b0f800f', + 'detail': '探索色彩奥秘,激发艺术潜能,创作属于自己的艺术作品。', + 'rating': 4.8, + 'duration': '8 课时' + }, + { + 'title': '小小主持人特训营', + 'project_type': 'training', + 'teacher': teachers[2], + 'image': 'https://images.unsplash.com/photo-1590602847861-f357a9332bbc', + 'detail': '提升自信心和表达能力,掌握舞台主持技巧。', + 'rating': 5.0, + 'duration': '10 课时' + }, + { + 'title': '全国青少年科技创新大赛', + 'project_type': 'competition', + 'teacher': teachers[3], + 'image': 'https://images.unsplash.com/photo-1532094349884-543bc11b234d', + 'detail': '备战全国大赛,专业导师一对一指导。', + 'rating': 5.0, + 'duration': '全程指导' + }, + { + 'title': '围棋段位考试冲刺', + 'project_type': 'grading', + 'teacher': teachers[0], # Reusing teacher for simplicity + 'image': 'https://images.unsplash.com/photo-1529699211952-734e80c4d42b', + 'detail': '针对段位考试重点难点进行突击训练。', + 'rating': 4.7, + 'duration': '2 周' + } + ] + + projects = [] + for data in projects_data: + proj, created = Project.objects.get_or_create(title=data['title'], defaults=data) + projects.append(proj) + + # 4. Create Coupons + self.stdout.write('Creating Coupons...') + coupons_data = [ + { + 'title': '新人体验券', + 'amount': '50', + 'unit': '元', + 'desc': '全场通用,无门槛', + 'expiry': timezone.now().date() + timedelta(days=30), + 'status': 'available', + 'issue_type': 'registration', + 'color': 'from-blue-500 to-cyan-400', + 'bgStart': 'from-blue-50', + 'bgEnd': 'to-cyan-50', + 'shadow': 'shadow-blue-100' + }, + { + 'title': '课程立减券', + 'amount': '100', + 'unit': '元', + 'desc': '满1000元可用', + 'expiry': timezone.now().date() + timedelta(days=60), + 'status': 'available', + 'issue_type': 'manual', + 'color': 'from-purple-500 to-pink-400', + 'bgStart': 'from-purple-50', + 'bgEnd': 'to-pink-50', + 'shadow': 'shadow-purple-100' + }, + { + 'title': '暑期特惠券', + 'amount': '200', + 'unit': '元', + 'desc': '暑期课程专用', + 'expiry': timezone.now().date() + timedelta(days=90), + 'status': 'available', + 'issue_type': 'manual', + 'color': 'from-orange-500 to-red-400', + 'bgStart': 'from-orange-50', + 'bgEnd': 'to-red-50', + 'shadow': 'shadow-orange-100' + } + ] + + coupons = [] + for data in coupons_data: + coup, created = Coupon.objects.get_or_create(title=data['title'], defaults=data) + coupons.append(coup) + + # 5. Create Banners + self.stdout.write('Creating Banners...') + banners_data = [ + { + 'image': 'https://images.unsplash.com/photo-1503676260728-1c00da094a0b', + 'project': projects[0], + 'sort_order': 1 + }, + { + 'image': 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', + 'project': projects[2], + 'sort_order': 2 + } + ] + + for data in banners_data: + Banner.objects.get_or_create(image=data['image'], defaults=data) + + # 6. Create Students + self.stdout.write('Creating Students...') + students_data = [ + { + 'name': '张小明', + 'phone': '13900000001', + 'age': 8, + 'parent': '张伟', + 'teacher': teachers[0], + 'avatar': 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde' + }, + { + 'name': '李小红', + 'phone': '13900000002', + 'age': 9, + 'parent': '李强', + 'teacher': teachers[1], + 'avatar': 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80' + }, + { + 'name': '王小华', + 'phone': '13900000003', + 'age': 10, + 'parent': '王磊', + 'teacher': teachers[2], + 'avatar': 'https://images.unsplash.com/photo-1599566150163-29194dcaad36' + } + ] + + students = [] + for data in students_data: + stu, created = Student.objects.get_or_create(name=data['name'], phone=data['phone'], defaults=data) + students.append(stu) + + # 7. Create Student Projects (Enrollments) + self.stdout.write('Enrolling Students...') + # Enroll first student in first project + StudentProject.objects.get_or_create( + student=students[0], + project=projects[0], + defaults={'status': 'enrolled'} + ) + # Enroll first student in second project (completed) + StudentProject.objects.get_or_create( + student=students[0], + project=projects[1], + defaults={'status': 'completed', 'completed_at': timezone.now()} + ) + + # 8. Create Student Honors + self.stdout.write('Creating Honors...') + honors_data = [ + { + 'student': students[0], + 'title': '优秀学员奖', + 'date': timezone.now().date() - timedelta(days=10), + 'image': 'https://images.unsplash.com/photo-1579548122080-c35fd6820ecb', + 'description': '在Python编程课程中表现优异,特发此证。' + }, + { + 'student': students[0], + 'title': '小小演说家', + 'date': timezone.now().date() - timedelta(days=50), + 'image': 'https://images.unsplash.com/photo-1579548122080-c35fd6820ecb', + 'description': '在演讲比赛中获得一等奖。' + } + ] + + for data in honors_data: + StudentHonor.objects.get_or_create(student=data['student'], title=data['title'], defaults=data) + + # 9. Assign Coupons to Student + self.stdout.write('Assigning Coupons...') + StudentCoupon.objects.get_or_create( + student=students[0], + coupon=coupons[0], + defaults={'status': 'assigned'} + ) + StudentCoupon.objects.get_or_create( + student=students[0], + coupon=coupons[1], + defaults={'status': 'used', 'used_at': timezone.now()} + ) + + self.stdout.write(self.style.SUCCESS('Successfully populated mock data!')) diff --git a/admin/server/apps/crm/migrations/0001_initial.py b/admin/server/apps/crm/migrations/0001_initial.py new file mode 100644 index 0000000..93f9d61 --- /dev/null +++ b/admin/server/apps/crm/migrations/0001_initial.py @@ -0,0 +1,99 @@ +# Generated by Django 3.2.23 on 2025-11-28 06:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Banner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.URLField(verbose_name='图片URL')), + ('link', models.CharField(blank=True, max_length=200, null=True, verbose_name='跳转链接')), + ('sort_order', models.IntegerField(default=0, verbose_name='排序')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ], + options={ + 'verbose_name': '轮播图', + 'verbose_name_plural': '轮播图', + 'ordering': ['sort_order'], + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='分类名称')), + ('color', models.CharField(help_text='例如: bg-blue-100 text-blue-600', max_length=50, verbose_name='颜色类名')), + ], + options={ + 'verbose_name': '课程分类', + 'verbose_name_plural': '课程分类', + }, + ), + migrations.CreateModel( + name='Coupon', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.CharField(max_length=10, verbose_name='面额')), + ('unit', models.CharField(default='元', max_length=10, verbose_name='单位')), + ('title', models.CharField(max_length=50, verbose_name='优惠券标题')), + ('desc', models.CharField(max_length=100, verbose_name='描述')), + ('expiry', models.DateField(verbose_name='过期时间')), + ('status', models.CharField(choices=[('available', '可领取'), ('claimed', '已领取'), ('used', '已使用'), ('expired', '已过期')], default='available', max_length=20, verbose_name='状态')), + ('color', models.CharField(default='from-blue-500 to-cyan-400', max_length=100, verbose_name='渐变色类名')), + ('bgStart', models.CharField(default='from-blue-50', max_length=50, verbose_name='背景起始色')), + ('bgEnd', models.CharField(default='to-cyan-50', max_length=50, verbose_name='背景结束色')), + ('shadow', models.CharField(default='shadow-blue-100', max_length=50, verbose_name='阴影类名')), + ], + options={ + 'verbose_name': '优惠券', + 'verbose_name_plural': '优惠券', + }, + ), + migrations.CreateModel( + name='Student', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='学员姓名')), + ('phone', models.CharField(max_length=20, verbose_name='联系电话')), + ('age', models.IntegerField(blank=True, null=True, verbose_name='年龄')), + ('education', models.CharField(blank=True, max_length=50, null=True, verbose_name='学历')), + ('address', models.CharField(blank=True, max_length=200, null=True, verbose_name='地址')), + ('avatar', models.URLField(default='https://images.unsplash.com/photo-1535713875002-d1d0cf377fde', verbose_name='头像URL')), + ('learning_count', models.IntegerField(default=0, verbose_name='在学课程数')), + ('coupon_count', models.IntegerField(default=0, verbose_name='优惠券数')), + ('study_hours', models.IntegerField(default=0, verbose_name='学习时长(小时)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='录入时间')), + ], + options={ + 'verbose_name': '学员信息', + 'verbose_name_plural': '学员信息', + }, + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='项目名称')), + ('image', models.URLField(default='https://images.unsplash.com/photo-1526379095098-d400fd0bf935', verbose_name='封面图片URL')), + ('students', models.IntegerField(default=0, verbose_name='学习人数')), + ('rating', models.DecimalField(decimal_places=1, default=5.0, max_digits=3, verbose_name='评分')), + ('duration', models.CharField(default='4 周', max_length=20, verbose_name='时长')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='crm.category', verbose_name='所属分类')), + ], + options={ + 'verbose_name': '项目课程', + 'verbose_name_plural': '项目课程', + }, + ), + ] diff --git a/admin/server/apps/crm/migrations/0002_project_detail.py b/admin/server/apps/crm/migrations/0002_project_detail.py new file mode 100644 index 0000000..b5adbc4 --- /dev/null +++ b/admin/server/apps/crm/migrations/0002_project_detail.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2025-11-28 07:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='detail', + field=models.TextField(blank=True, default='', verbose_name='项目详情'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0003_banner_project.py b/admin/server/apps/crm/migrations/0003_banner_project.py new file mode 100644 index 0000000..fb7a655 --- /dev/null +++ b/admin/server/apps/crm/migrations/0003_banner_project.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.23 on 2025-11-28 07:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0002_project_detail'), + ] + + operations = [ + migrations.AddField( + model_name='banner', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='crm.project', verbose_name='关联项目'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0004_auto_20251128_1719.py b/admin/server/apps/crm/migrations/0004_auto_20251128_1719.py new file mode 100644 index 0000000..a9c2bc1 --- /dev/null +++ b/admin/server/apps/crm/migrations/0004_auto_20251128_1719.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.23 on 2025-11-28 09:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0003_banner_project'), + ] + + operations = [ + migrations.AddField( + model_name='coupon', + name='applicable_projects', + field=models.ManyToManyField(blank=True, related_name='coupons', to='crm.Project', verbose_name='适用项目'), + ), + migrations.AddField( + model_name='coupon', + name='issue_type', + field=models.CharField(choices=[('manual', '手动发放'), ('registration', '注册赠送'), ('project', '指定项目')], default='manual', max_length=20, verbose_name='发放类型'), + ), + migrations.CreateModel( + name='StudentCoupon', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('assigned', '已分配'), ('used', '已使用'), ('expired', '已过期')], default='assigned', max_length=20, verbose_name='状态')), + ('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='分配时间')), + ('used_at', models.DateTimeField(blank=True, null=True, verbose_name='使用时间')), + ('coupon', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='crm.coupon', verbose_name='优惠券')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_coupons', to='crm.student', verbose_name='学员')), + ], + options={ + 'verbose_name': '学员优惠券', + 'verbose_name_plural': '学员优惠券', + 'ordering': ['-assigned_at'], + }, + ), + ] diff --git a/admin/server/apps/crm/migrations/0005_remove_student_education_student_parent_and_more.py b/admin/server/apps/crm/migrations/0005_remove_student_education_student_parent_and_more.py new file mode 100644 index 0000000..5f1d579 --- /dev/null +++ b/admin/server/apps/crm/migrations/0005_remove_student_education_student_parent_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.26 on 2025-11-29 13:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0004_auto_20251128_1719'), + ] + + operations = [ + migrations.RemoveField( + model_name='student', + name='education', + ), + migrations.AddField( + model_name='student', + name='parent', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='家长'), + ), + migrations.AddField( + model_name='student', + name='teacher', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='负责老师'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0006_student_wechat_nickname.py b/admin/server/apps/crm/migrations/0006_student_wechat_nickname.py new file mode 100644 index 0000000..91874cd --- /dev/null +++ b/admin/server/apps/crm/migrations/0006_student_wechat_nickname.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2025-11-29 14:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0005_remove_student_education_student_parent_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='student', + name='wechat_nickname', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='微信昵称'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0007_project_project_type_studentproject.py b/admin/server/apps/crm/migrations/0007_project_project_type_studentproject.py new file mode 100644 index 0000000..0118475 --- /dev/null +++ b/admin/server/apps/crm/migrations/0007_project_project_type_studentproject.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.26 on 2025-11-29 14:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0006_student_wechat_nickname'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='project_type', + field=models.CharField(choices=[('training', '小主持语言培训'), ('competition', '赛事管理'), ('grading', '考级管理')], default='training', max_length=20, verbose_name='项目类型'), + ), + migrations.CreateModel( + name='StudentProject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('enrolled', '已报名'), ('completed', '已完成'), ('cancelled', '已取消')], default='enrolled', max_length=20, verbose_name='状态')), + ('enrolled_at', models.DateTimeField(auto_now_add=True, verbose_name='报名时间')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='crm.project', verbose_name='项目')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='crm.student', verbose_name='学员')), + ], + options={ + 'verbose_name': '学员项目报名', + 'verbose_name_plural': '学员项目报名', + 'ordering': ['-enrolled_at'], + 'unique_together': {('student', 'project')}, + }, + ), + ] diff --git a/admin/server/apps/crm/migrations/0008_teacher_remove_project_category_project_teacher_and_more.py b/admin/server/apps/crm/migrations/0008_teacher_remove_project_category_project_teacher_and_more.py new file mode 100644 index 0000000..fdc8d22 --- /dev/null +++ b/admin/server/apps/crm/migrations/0008_teacher_remove_project_category_project_teacher_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.26 on 2025-11-29 14:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0007_project_project_type_studentproject'), + ] + + operations = [ + migrations.CreateModel( + name='Teacher', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='教师姓名')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='联系电话')), + ('bio', models.TextField(blank=True, default='', verbose_name='简介')), + ], + options={ + 'verbose_name': '教师', + 'verbose_name_plural': '教师', + }, + ), + migrations.RemoveField( + model_name='project', + name='category', + ), + migrations.AddField( + model_name='project', + name='teacher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='crm.teacher', verbose_name='负责老师'), + ), + migrations.AlterField( + model_name='student', + name='teacher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='crm.teacher', verbose_name='负责老师'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0009_studenthonor.py b/admin/server/apps/crm/migrations/0009_studenthonor.py new file mode 100644 index 0000000..07ef299 --- /dev/null +++ b/admin/server/apps/crm/migrations/0009_studenthonor.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.8 on 2025-11-29 15:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0008_teacher_remove_project_category_project_teacher_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='StudentHonor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='荣誉标题')), + ('image', models.URLField(default='https://images.unsplash.com/photo-1579548122080-c35fd6820ecb', verbose_name='证书图片URL')), + ('date', models.DateField(verbose_name='获得日期')), + ('description', models.TextField(blank=True, default='', verbose_name='荣誉描述')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='honors', to='crm.student', verbose_name='学员')), + ], + options={ + 'verbose_name': '学员荣誉', + 'verbose_name_plural': '学员荣誉', + 'ordering': ['-date'], + }, + ), + ] diff --git a/admin/server/apps/crm/migrations/0010_studentshowcase.py b/admin/server/apps/crm/migrations/0010_studentshowcase.py new file mode 100644 index 0000000..31d8a06 --- /dev/null +++ b/admin/server/apps/crm/migrations/0010_studentshowcase.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.26 on 2025-11-29 16:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0009_studenthonor'), + ] + + operations = [ + migrations.CreateModel( + name='StudentShowcase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='标题')), + ('cover_image', models.URLField(verbose_name='封面图片URL')), + ('video_url', models.URLField(blank=True, null=True, verbose_name='视频链接URL')), + ('description', models.TextField(blank=True, default='', verbose_name='描述')), + ('sort_order', models.IntegerField(default=0, verbose_name='排序')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('student', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='showcases', to='crm.student', verbose_name='关联学员')), + ], + options={ + 'verbose_name': '学员风采', + 'verbose_name_plural': '学员风采', + 'ordering': ['sort_order', '-created_at'], + }, + ), + ] diff --git a/admin/server/apps/crm/migrations/0011_project_price_project_show_price.py b/admin/server/apps/crm/migrations/0011_project_price_project_show_price.py new file mode 100644 index 0000000..8e3ebfb --- /dev/null +++ b/admin/server/apps/crm/migrations/0011_project_price_project_show_price.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.26 on 2025-12-01 14:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0010_studentshowcase'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='price', + field=models.DecimalField(decimal_places=2, default=299.0, max_digits=10, verbose_name='价格'), + ), + migrations.AddField( + model_name='project', + name='show_price', + field=models.BooleanField(default=True, verbose_name='显示价格'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0012_alter_studentcoupon_status.py b/admin/server/apps/crm/migrations/0012_alter_studentcoupon_status.py new file mode 100644 index 0000000..8848b89 --- /dev/null +++ b/admin/server/apps/crm/migrations/0012_alter_studentcoupon_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2025-12-01 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0011_project_price_project_show_price'), + ] + + operations = [ + migrations.AlterField( + model_name='studentcoupon', + name='status', + field=models.CharField(choices=[('assigned', '已分配'), ('used', '已使用'), ('expired', '已过期'), ('revoked', '已回收'), ('suspended', '暂停使用')], default='assigned', max_length=20, verbose_name='状态'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0013_student_openid.py b/admin/server/apps/crm/migrations/0013_student_openid.py new file mode 100644 index 0000000..fdc28e0 --- /dev/null +++ b/admin/server/apps/crm/migrations/0013_student_openid.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2025-12-01 16:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0012_alter_studentcoupon_status'), + ] + + operations = [ + migrations.AddField( + model_name='student', + name='openid', + field=models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='微信OpenID'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0014_alter_student_phone.py b/admin/server/apps/crm/migrations/0014_alter_student_phone.py new file mode 100644 index 0000000..e88590f --- /dev/null +++ b/admin/server/apps/crm/migrations/0014_alter_student_phone.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.26 on 2025-12-01 17:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0013_student_openid'), + ] + + operations = [ + migrations.AlterField( + model_name='student', + name='phone', + field=models.CharField(blank=True, max_length=20, null=True, verbose_name='联系电话'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0015_auto_20251202_1204.py b/admin/server/apps/crm/migrations/0015_auto_20251202_1204.py new file mode 100644 index 0000000..9535713 --- /dev/null +++ b/admin/server/apps/crm/migrations/0015_auto_20251202_1204.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.23 on 2025-12-02 04:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0014_alter_student_phone'), + ] + + operations = [ + migrations.AlterModelOptions( + name='studentshowcase', + options={'ordering': ['sort_order', '-created_at'], 'verbose_name': '精彩视频', 'verbose_name_plural': '精彩视频'}, + ), + migrations.AlterField( + model_name='project', + name='project_type', + field=models.CharField(choices=[('training', '学位项目'), ('competition', '典礼&论坛'), ('grading', '校友活动')], default='training', max_length=20, verbose_name='项目类型'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0016_alter_project_teacher.py b/admin/server/apps/crm/migrations/0016_alter_project_teacher.py new file mode 100644 index 0000000..c289920 --- /dev/null +++ b/admin/server/apps/crm/migrations/0016_alter_project_teacher.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.23 on 2025-12-02 04:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0015_auto_20251202_1204'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='teacher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='crm.teacher', verbose_name='教学中心'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0017_auto_20251202_1422.py b/admin/server/apps/crm/migrations/0017_auto_20251202_1422.py new file mode 100644 index 0000000..8f1d1e5 --- /dev/null +++ b/admin/server/apps/crm/migrations/0017_auto_20251202_1422.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.23 on 2025-12-02 06:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0016_alter_project_teacher'), + ] + + operations = [ + migrations.AddField( + model_name='student', + name='company_name', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='公司名称'), + ), + migrations.AddField( + model_name='student', + name='teaching_center', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='关联教学中心'), + ), + migrations.AlterField( + model_name='student', + name='avatar', + field=models.URLField(default='https://images.unsplash.com/photo-1535713875002-d1d0cf377fde', verbose_name='微信头像'), + ), + migrations.AlterField( + model_name='student', + name='openid', + field=models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='微信唯一标识'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0018_auto_20251202_1430.py b/admin/server/apps/crm/migrations/0018_auto_20251202_1430.py new file mode 100644 index 0000000..fef74cb --- /dev/null +++ b/admin/server/apps/crm/migrations/0018_auto_20251202_1430.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.23 on 2025-12-02 06:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0017_auto_20251202_1422'), + ] + + operations = [ + migrations.RemoveField( + model_name='student', + name='parent', + ), + migrations.RemoveField( + model_name='student', + name='teacher', + ), + ] diff --git a/admin/server/apps/crm/migrations/0019_auto_20251202_1443.py b/admin/server/apps/crm/migrations/0019_auto_20251202_1443.py new file mode 100644 index 0000000..9bcc25d --- /dev/null +++ b/admin/server/apps/crm/migrations/0019_auto_20251202_1443.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.23 on 2025-12-02 06:43 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0018_auto_20251202_1430'), + ] + + operations = [ + migrations.CreateModel( + name='TeachingCenter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='教学中心名称')), + ('address', models.CharField(blank=True, max_length=200, null=True, verbose_name='地址')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='联系电话')), + ], + options={ + 'verbose_name': '教学中心', + 'verbose_name_plural': '教学中心', + }, + ), + migrations.AddField( + model_name='student', + name='status', + field=models.CharField(choices=[('ordinary', '普通用户'), ('intention', '意向用户'), ('reading', '在读'), ('graduated', '毕业')], default='ordinary', max_length=20, verbose_name='状态'), + ), + migrations.AlterField( + model_name='student', + name='teaching_center', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='crm.teachingcenter', verbose_name='关联教学中心'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0020_student_teacher.py b/admin/server/apps/crm/migrations/0020_student_teacher.py new file mode 100644 index 0000000..46e429c --- /dev/null +++ b/admin/server/apps/crm/migrations/0020_student_teacher.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.23 on 2025-12-02 07:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0019_auto_20251202_1443'), + ] + + operations = [ + migrations.AddField( + model_name='student', + name='teacher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='crm.teacher', verbose_name='负责老师'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0021_alter_studentproject_status.py b/admin/server/apps/crm/migrations/0021_alter_studentproject_status.py new file mode 100644 index 0000000..9b9489b --- /dev/null +++ b/admin/server/apps/crm/migrations/0021_alter_studentproject_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2025-12-03 04:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0020_student_teacher'), + ] + + operations = [ + migrations.AlterField( + model_name='studentproject', + name='status', + field=models.CharField(choices=[('enrolled', '已报名'), ('studying', '在读'), ('graduated', '毕业'), ('completed', '已完成'), ('cancelled', '已取消')], default='enrolled', max_length=20, verbose_name='状态'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0022_auto_20251203_1637.py b/admin/server/apps/crm/migrations/0022_auto_20251203_1637.py new file mode 100644 index 0000000..e60bfa8 --- /dev/null +++ b/admin/server/apps/crm/migrations/0022_auto_20251203_1637.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.23 on 2025-12-03 08:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0021_alter_studentproject_status'), + ] + + operations = [ + migrations.AlterModelOptions( + name='project', + options={'ordering': ['-is_top', 'sort_order', '-created_at'], 'verbose_name': '项目课程', 'verbose_name_plural': '项目课程'}, + ), + migrations.AddField( + model_name='project', + name='is_top', + field=models.BooleanField(default=False, verbose_name='是否置顶'), + ), + migrations.AddField( + model_name='project', + name='sort_order', + field=models.IntegerField(default=0, verbose_name='排序'), + ), + migrations.AlterField( + model_name='project', + name='project_type', + field=models.CharField(choices=[('training', '在职项目'), ('competition', '典礼&论坛'), ('grading', '校友活动')], default='training', max_length=20, verbose_name='项目类型'), + ), + migrations.AlterField( + model_name='studentproject', + name='status', + field=models.CharField(choices=[('enrolled', '已报名'), ('studying', '在读'), ('graduated', '毕业'), ('finished', '结束')], default='enrolled', max_length=20, verbose_name='状态'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0023_auto_20251203_1645.py b/admin/server/apps/crm/migrations/0023_auto_20251203_1645.py new file mode 100644 index 0000000..76208c4 --- /dev/null +++ b/admin/server/apps/crm/migrations/0023_auto_20251203_1645.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.23 on 2025-12-03 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0022_auto_20251203_1637'), + ] + + operations = [ + migrations.RemoveField( + model_name='project', + name='rating', + ), + migrations.AddField( + model_name='project', + name='address', + field=models.CharField(blank=True, default='', max_length=200, verbose_name='地址'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0024_project_custom_teacher.py b/admin/server/apps/crm/migrations/0024_project_custom_teacher.py new file mode 100644 index 0000000..7c026c1 --- /dev/null +++ b/admin/server/apps/crm/migrations/0024_project_custom_teacher.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2025-12-03 08:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0023_auto_20251203_1645'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='custom_teacher', + field=models.CharField(blank=True, default='', max_length=100, verbose_name='自定义教学中心'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0025_auto_20251204_1044.py b/admin/server/apps/crm/migrations/0025_auto_20251204_1044.py new file mode 100644 index 0000000..aec2620 --- /dev/null +++ b/admin/server/apps/crm/migrations/0025_auto_20251204_1044.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.23 on 2025-12-04 02:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0024_project_custom_teacher'), + ] + + operations = [ + migrations.AlterModelOptions( + name='teacher', + options={'verbose_name': '教学与管理中心', 'verbose_name_plural': '教学与管理中心'}, + ), + migrations.AddField( + model_name='project', + name='is_active', + field=models.BooleanField(default=True, verbose_name='是否启用'), + ), + migrations.AlterField( + model_name='teacher', + name='name', + field=models.CharField(max_length=50, verbose_name='教学与管理中心'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0026_student_position.py b/admin/server/apps/crm/migrations/0026_student_position.py new file mode 100644 index 0000000..c9b814f --- /dev/null +++ b/admin/server/apps/crm/migrations/0026_student_position.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2025-12-04 07:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0025_auto_20251204_1044'), + ] + + operations = [ + migrations.AddField( + model_name='student', + name='position', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='职位'), + ), + ] diff --git a/admin/server/apps/crm/migrations/0027_coupon_is_time_limited.py b/admin/server/apps/crm/migrations/0027_coupon_is_time_limited.py new file mode 100644 index 0000000..6b6a024 --- /dev/null +++ b/admin/server/apps/crm/migrations/0027_coupon_is_time_limited.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2025-12-05 05:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0026_student_position'), + ] + + operations = [ + migrations.AddField( + model_name='coupon', + name='is_time_limited', + field=models.BooleanField(default=False, verbose_name='是否限时优惠'), + ), + ] diff --git a/admin/server/apps/crm/migrations/__init__.py b/admin/server/apps/crm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/crm/models.py b/admin/server/apps/crm/models.py new file mode 100644 index 0000000..13e40c1 --- /dev/null +++ b/admin/server/apps/crm/models.py @@ -0,0 +1,305 @@ +from django.db import models +from apps.system.models import User +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + +class Category(models.Model): + name = models.CharField(max_length=50, verbose_name="分类名称") + color = models.CharField(max_length=50, verbose_name="颜色类名", help_text="例如: bg-blue-100 text-blue-600") + + class Meta: + verbose_name = "课程分类" + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + +class Teacher(models.Model): + name = models.CharField(max_length=50, verbose_name="教学与管理中心") + phone = models.CharField(max_length=20, verbose_name="联系电话", null=True, blank=True) + bio = models.TextField(verbose_name="简介", blank=True, default="") + + class Meta: + verbose_name = "教学与管理中心" + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + +class TeachingCenter(models.Model): + name = models.CharField(max_length=100, verbose_name="教学中心名称") + address = models.CharField(max_length=200, verbose_name="地址", null=True, blank=True) + phone = models.CharField(max_length=20, verbose_name="联系电话", null=True, blank=True) + + class Meta: + verbose_name = "教学中心" + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + +class Project(models.Model): + PROJECT_TYPE_CHOICES = ( + ('training', '在职项目'), + ('competition', '典礼&论坛'), + ('grading', '校友活动'), + ) + title = models.CharField(max_length=100, verbose_name="项目名称") + project_type = models.CharField(max_length=20, choices=PROJECT_TYPE_CHOICES, default='training', verbose_name="项目类型") + # category = models.ForeignKey(Category, on_delete=models.CASCADE, verbose_name="所属分类", related_name="projects") + teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="教学中心", related_name="projects") + custom_teacher = models.CharField(max_length=100, verbose_name="自定义教学中心", default="", blank=True) + image = models.URLField(verbose_name="封面图片URL", default="https://images.unsplash.com/photo-1526379095098-d400fd0bf935") + detail = models.TextField(verbose_name="项目详情", blank=True, default="") + students = models.IntegerField(default=0, verbose_name="学习人数") + address = models.CharField(max_length=200, verbose_name="地址", default="", blank=True) + duration = models.CharField(max_length=20, verbose_name="时长", default="4 周") + price = models.DecimalField(max_digits=10, decimal_places=2, default=299.00, verbose_name="价格") + show_price = models.BooleanField(default=True, verbose_name="显示价格") + sort_order = models.IntegerField(default=0, verbose_name="排序") + is_top = models.BooleanField(default=False, verbose_name="是否置顶") + is_active = models.BooleanField(default=True, verbose_name="是否启用") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + class Meta: + verbose_name = "项目课程" + verbose_name_plural = verbose_name + ordering = ['-is_top', 'sort_order', '-created_at'] + + def __str__(self): + return self.title + +class Coupon(models.Model): + STATUS_CHOICES = ( + ('available', '可领取'), + ('claimed', '已领取'), + ('used', '已使用'), + ('expired', '已过期'), + ) + ISSUE_CHOICES = ( + ('manual', '手动发放'), + ('registration', '注册赠送'), + ('project', '指定项目'), + ) + + amount = models.CharField(max_length=10, verbose_name="面额") + unit = models.CharField(max_length=10, verbose_name="单位", default="元") + title = models.CharField(max_length=50, verbose_name="优惠券标题") + desc = models.CharField(max_length=100, verbose_name="描述") + expiry = models.DateField(verbose_name="过期时间") + is_time_limited = models.BooleanField(default=False, verbose_name="是否限时优惠") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='available', verbose_name="状态") + issue_type = models.CharField(max_length=20, choices=ISSUE_CHOICES, default='manual', verbose_name="发放类型") + applicable_projects = models.ManyToManyField(Project, blank=True, related_name="coupons", verbose_name="适用项目") + + # UI样式字段 + color = models.CharField(max_length=100, verbose_name="渐变色类名", default="from-blue-500 to-cyan-400") + bgStart = models.CharField(max_length=50, verbose_name="背景起始色", default="from-blue-50") + bgEnd = models.CharField(max_length=50, verbose_name="背景结束色", default="to-cyan-50") + shadow = models.CharField(max_length=50, verbose_name="阴影类名", default="shadow-blue-100") + + class Meta: + verbose_name = "优惠券" + verbose_name_plural = verbose_name + + def __str__(self): + return self.title + +class Banner(models.Model): + image = models.URLField(verbose_name="图片URL") + project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联项目") + link = models.CharField(max_length=200, blank=True, null=True, verbose_name="跳转链接") + sort_order = models.IntegerField(default=0, verbose_name="排序") + is_active = models.BooleanField(default=True, verbose_name="是否启用") + + class Meta: + verbose_name = "轮播图" + verbose_name_plural = verbose_name + ordering = ['sort_order'] + + def __str__(self): + return f"Banner {self.id}" + +class Student(models.Model): + name = models.CharField(max_length=50, verbose_name="学员姓名") + phone = models.CharField(max_length=20, verbose_name="联系电话", null=True, blank=True) + age = models.IntegerField(verbose_name="年龄", null=True, blank=True) + wechat_nickname = models.CharField(max_length=100, verbose_name="微信昵称", null=True, blank=True) + # 已经有了openid字段,所以这里不需要重复添加,只需要确保名称正确即可。 + # 原有 openid 字段定义: openid = models.CharField(max_length=100, verbose_name="微信OpenID", null=True, blank=True, unique=True) + # 用户需求是“微信唯一标识”,这通常指 OpenID。 + openid = models.CharField(max_length=100, verbose_name="微信唯一标识", null=True, blank=True, unique=True) + # parent = models.CharField(max_length=50, verbose_name="家长", null=True, blank=True) + teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="负责老师", related_name="students") + address = models.CharField(max_length=200, verbose_name="地址", null=True, blank=True) + # 已经有avatar字段,对应微信头像 + avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde") + + # 新增字段 + teaching_center = models.ForeignKey(TeachingCenter, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联教学中心") + company_name = models.CharField(max_length=100, verbose_name="公司名称", null=True, blank=True) + position = models.CharField(max_length=100, verbose_name="职位", null=True, blank=True) + + STATUS_CHOICES = ( + ('ordinary', '普通用户'), + ('intention', '意向用户'), + ('reading', '在读'), + ('graduated', '毕业'), + ) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ordinary', verbose_name="状态") + + # 统计数据 + learning_count = models.IntegerField(default=0, verbose_name="在学课程数") + coupon_count = models.IntegerField(default=0, verbose_name="优惠券数") + study_hours = models.IntegerField(default=0, verbose_name="学习时长(小时)") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="录入时间") + + class Meta: + verbose_name = "学员信息" + verbose_name_plural = verbose_name + + def __str__(self): + return self.name + +class StudentCoupon(models.Model): + STATUS_CHOICES = ( + ('assigned', '已分配'), + ('used', '已使用'), + ('expired', '已过期'), + ('revoked', '已回收'), + ('suspended', '暂停使用'), + ) + student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name="student_coupons", verbose_name="学员") + coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE, related_name="assignments", verbose_name="优惠券") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='assigned', verbose_name="状态") + assigned_at = models.DateTimeField(auto_now_add=True, verbose_name="分配时间") + used_at = models.DateTimeField(null=True, blank=True, verbose_name="使用时间") + + class Meta: + verbose_name = "学员优惠券" + verbose_name_plural = verbose_name + ordering = ['-assigned_at'] + + def __str__(self): + return f"{self.student.name} - {self.coupon.title}" + +@receiver(post_save, sender=StudentCoupon) +def update_student_coupon_count_on_save(sender, instance, created, **kwargs): + student = instance.student + student.coupon_count = StudentCoupon.objects.filter(student=student, status='assigned').count() + student.save(update_fields=['coupon_count']) + +@receiver(post_delete, sender=StudentCoupon) +def update_student_coupon_count_on_delete(sender, instance, **kwargs): + try: + student = instance.student + # Only update if student still exists (avoid error when deleting student triggers cascade) + if Student.objects.filter(pk=student.pk).exists(): + student.coupon_count = StudentCoupon.objects.filter(student=student, status='assigned').count() + student.save(update_fields=['coupon_count']) + except Exception: + pass + +class StudentProject(models.Model): + STATUS_CHOICES = ( + ('enrolled', '已报名'), + ('studying', '在读'), + ('graduated', '毕业'), + ('finished', '结束'), + ) + student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name="enrollments", verbose_name="学员") + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="enrollments", verbose_name="项目") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='enrolled', verbose_name="状态") + enrolled_at = models.DateTimeField(auto_now_add=True, verbose_name="报名时间") + completed_at = models.DateTimeField(blank=True, null=True, verbose_name="完成时间") + + class Meta: + verbose_name = "学员项目报名" + verbose_name_plural = verbose_name + ordering = ['-enrolled_at'] + unique_together = ('student', 'project') + + def __str__(self): + return f"{self.student.name} - {self.project.title}" + +@receiver(post_save, sender=StudentProject) +def update_student_learning_count_on_save(sender, instance, created, **kwargs): + student = instance.student + # Count projects with status 'enrolled' or 'studying' + count = StudentProject.objects.filter( + student=student, + status__in=['enrolled', 'studying'] + ).count() + student.learning_count = count + student.save(update_fields=['learning_count']) + +@receiver(post_delete, sender=StudentProject) +def update_student_learning_count_on_delete(sender, instance, **kwargs): + try: + student = instance.student + if Student.objects.filter(pk=student.pk).exists(): + count = StudentProject.objects.filter( + student=student, + status__in=['enrolled', 'studying'] + ).count() + student.learning_count = count + student.save(update_fields=['learning_count']) + except Exception: + pass + +class StudentHonor(models.Model): + student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name="honors", verbose_name="学员") + title = models.CharField(max_length=100, verbose_name="荣誉标题") + image = models.URLField(verbose_name="证书图片URL", default="https://images.unsplash.com/photo-1579548122080-c35fd6820ecb") + date = models.DateField(verbose_name="获得日期") + description = models.TextField(verbose_name="荣誉描述", blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + class Meta: + verbose_name = "学员荣誉" + verbose_name_plural = verbose_name + ordering = ['-date'] + + def __str__(self): + return f"{self.student.name} - {self.title}" + +class StudentShowcase(models.Model): + title = models.CharField(max_length=100, verbose_name="标题") + cover_image = models.URLField(verbose_name="封面图片URL") + video_url = models.URLField(verbose_name="视频链接URL", blank=True, null=True) + description = models.TextField(verbose_name="描述", blank=True, default="") + student = models.ForeignKey(Student, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联学员", related_name="showcases") + sort_order = models.IntegerField(default=0, verbose_name="排序") + is_active = models.BooleanField(default=True, verbose_name="是否启用") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + class Meta: + verbose_name = "精彩视频" + verbose_name_plural = verbose_name + ordering = ['sort_order', '-created_at'] + + def __str__(self): + return self.title + +@receiver(post_save, sender=Student) +def activate_coupons_on_phone_bind(sender, instance, created, **kwargs): + # If we are just updating coupon_count or learning_count, do not trigger coupon issuance + update_fields = kwargs.get('update_fields') + if update_fields: + # Allow if other fields are present, but if ONLY these statistics are updated, skip. + ignored_fields = {'coupon_count', 'learning_count'} + if set(update_fields).issubset(ignored_fields): + return + + if instance.phone: + # 自动发放注册类型的优惠券 + reg_coupons = Coupon.objects.filter(issue_type='registration', status='available') + for coupon in reg_coupons: + # Use filter().exists() to check for existence to avoid MultipleObjectsReturned error + if not StudentCoupon.objects.filter(student=instance, coupon=coupon).exists(): + StudentCoupon.objects.create( + student=instance, + coupon=coupon, + status='assigned' + ) diff --git a/admin/server/apps/crm/serializers.py b/admin/server/apps/crm/serializers.py new file mode 100644 index 0000000..b1454e0 --- /dev/null +++ b/admin/server/apps/crm/serializers.py @@ -0,0 +1,129 @@ +from rest_framework import serializers +from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = '__all__' + +class TeacherSerializer(serializers.ModelSerializer): + class Meta: + model = Teacher + fields = '__all__' + +class TeachingCenterSerializer(serializers.ModelSerializer): + class Meta: + model = TeachingCenter + fields = '__all__' + +class ProjectSerializer(serializers.ModelSerializer): + # category_name = serializers.CharField(source='category.name', read_only=True) + # category_color = serializers.CharField(source='category.color', read_only=True) + teacher_name = serializers.SerializerMethodField() + project_type_display = serializers.CharField(source='get_project_type_display', read_only=True) + + class Meta: + model = Project + fields = '__all__' + + def get_teacher_name(self, obj): + if obj.teacher: + return obj.teacher.name + return obj.custom_teacher + +class CouponSerializer(serializers.ModelSerializer): + scope_text = serializers.SerializerMethodField() + applicable_project_titles = serializers.SerializerMethodField() + + class Meta: + model = Coupon + fields = '__all__' + + def get_scope_text(self, obj): + ids = list(obj.applicable_projects.values_list('id', flat=True)) + if not ids: + return '通用' + names = list(obj.applicable_projects.values_list('title', flat=True)) + return ','.join(names) + + def get_applicable_project_titles(self, obj): + return list(obj.applicable_projects.values_list('title', flat=True)) + +class StudentCouponSerializer(serializers.ModelSerializer): + coupon_detail = CouponSerializer(source='coupon', read_only=True) + student_name = serializers.CharField(source='student.name', read_only=True) + student_phone = serializers.CharField(source='student.phone', read_only=True) + coupon_title = serializers.CharField(source='coupon.title', read_only=True) + status_display = serializers.CharField(source='get_status_display', read_only=True) + + class Meta: + model = StudentCoupon + fields = '__all__' + +class BannerSerializer(serializers.ModelSerializer): + project_title = serializers.CharField(source='project.title', read_only=True) + class Meta: + model = Banner + fields = '__all__' + +class StudentSerializer(serializers.ModelSerializer): + teacher_name = serializers.CharField(source='teacher.name', read_only=True) + teaching_center_name = serializers.CharField(source='teaching_center.name', read_only=True) + stats = serializers.SerializerMethodField() + enrolled_projects = serializers.SerializerMethodField() + coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons') + status_display = serializers.CharField(source='get_status_display', read_only=True) + + class Meta: + model = Student + fields = ['id', 'name', 'phone', 'age', 'address', 'avatar', 'wechat_nickname', 'openid', 'teacher', 'teacher_name', 'teaching_center', 'teaching_center_name', 'company_name', 'position', 'status', 'status_display', 'stats', 'enrolled_projects', 'coupons', 'created_at'] + read_only_fields = ['stats', 'enrolled_projects', 'coupons', 'teaching_center_name', 'teacher_name', 'status_display'] + + def get_stats(self, obj): + return { + "learning": obj.learning_count, + "coupons": obj.coupon_count, + "hours": obj.study_hours + } + + def get_enrolled_projects(self, obj): + enrollments = obj.enrollments.all() + data = [] + for enrollment in enrollments: + data.append({ + "project_title": enrollment.project.title, + "status": enrollment.status, + "status_display": enrollment.get_status_display() + }) + return data + +class StudentProjectSerializer(serializers.ModelSerializer): + student_name = serializers.CharField(source='student.name', read_only=True) + student_phone = serializers.CharField(source='student.phone', read_only=True) + project_title = serializers.CharField(source='project.title', read_only=True) + project_image = serializers.CharField(source='project.image', read_only=True) + project_type = serializers.CharField(source='project.project_type', read_only=True) + project_type_display = serializers.CharField(source='project.get_project_type_display', read_only=True) + + class Meta: + model = StudentProject + fields = '__all__' + +class StudentHonorSerializer(serializers.ModelSerializer): + student_name = serializers.CharField(source='student.name', read_only=True) + student_phone = serializers.CharField(source='student.phone', read_only=True) + student_avatar = serializers.CharField(source='student.avatar', read_only=True) + student_openid = serializers.CharField(source='student.openid', read_only=True) + student_teaching_center = serializers.CharField(source='student.teacher.name', read_only=True) + student_company_name = serializers.CharField(source='student.company_name', read_only=True) + + class Meta: + model = StudentHonor + fields = '__all__' + +class StudentShowcaseSerializer(serializers.ModelSerializer): + student_name = serializers.CharField(source='student.name', read_only=True) + + class Meta: + model = StudentShowcase + fields = '__all__' diff --git a/admin/server/apps/crm/tests.py b/admin/server/apps/crm/tests.py new file mode 100644 index 0000000..f2f9d1e --- /dev/null +++ b/admin/server/apps/crm/tests.py @@ -0,0 +1,50 @@ +from django.test import TestCase +from rest_framework.test import APIClient +from django.urls import reverse +from .models import Student, Teacher, Project, Banner, StudentCoupon, StudentShowcase, StudentProject +from django.utils import timezone +from datetime import timedelta + + +class DashboardStatsViewTests(TestCase): + def setUp(self): + # Create minimal data + self.student = Student.objects.create(name='测试学员') + self.teacher = Teacher.objects.create(name='机构A') + Project.objects.create(title='项目1', teacher=self.teacher, is_active=True) + Banner.objects.create(image='http://example.com/a.png', is_active=True) + StudentShowcase.objects.create(title='展示1', cover_image='http://example.com/c.png', is_active=True) + # 优惠券在具体用例中创建 + + def test_requires_authentication(self): + client = APIClient() + url = '/api/dashboard/stats/' + resp = client.get(url) + self.assertEqual(resp.status_code, 401) + + def test_returns_basic_stats_with_auth(self): + # Create valid coupon and assignment + from .models import Coupon + coupon = Coupon.objects.create(title='C1', amount='10', unit='元', desc='D', expiry=timezone.now().date()) + StudentCoupon.objects.create(student=self.student, coupon=coupon) + + # Enrollment for trends + StudentProject.objects.create(student=self.student, project=Project.objects.first()) + + client = APIClient() + token = f"mock_token_{self.student.id}" + resp = client.get('/api/dashboard/stats/', HTTP_AUTHORIZATION=f'Bearer {token}') + self.assertEqual(resp.status_code, 200) + data = resp.json() + # Panel data checks + self.assertIn('panel_data', data) + panel = data['panel_data'] + for key in ['students', 'organizations', 'projects', 'projects_active', 'coupons', 'banners_active', 'showcases_active']: + self.assertIn(key, panel) + # Charts existence + self.assertIn('pie_chart_data', data) + self.assertIn('coupon_pie_chart_data', data) + self.assertIn('bar_chart_data', data) + self.assertIn('line_chart_data', data) + +# Create your tests here. diff --git a/admin/server/apps/crm/urls.py b/admin/server/apps/crm/urls.py new file mode 100644 index 0000000..9f8aedf --- /dev/null +++ b/admin/server/apps/crm/urls.py @@ -0,0 +1,28 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import CategoryViewSet, TeacherViewSet, TeachingCenterViewSet, ProjectViewSet, CouponViewSet, BannerViewSet, StudentViewSet, StudentCouponViewSet, StudentProjectViewSet, StudentHonorViewSet, StudentShowcaseViewSet, UserProfileView, UserCouponsView, UserProjectsView, UserHonorsView, LoginView, UserPhoneView, DashboardStatsView, AvailableCouponsView + +router = DefaultRouter() +router.register(r'categories', CategoryViewSet) +router.register(r'teachers', TeacherViewSet) +router.register(r'teaching-centers', TeachingCenterViewSet) +router.register(r'projects', ProjectViewSet) +router.register(r'coupons', CouponViewSet) +router.register(r'banners', BannerViewSet) +router.register(r'students', StudentViewSet) +router.register(r'student-coupons', StudentCouponViewSet) +router.register(r'student-projects', StudentProjectViewSet) +router.register(r'student-honors', StudentHonorViewSet) +router.register(r'student-showcases', StudentShowcaseViewSet) + +urlpatterns = [ + path('', include(router.urls)), + path('auth/login/', LoginView.as_view()), + path('dashboard/stats/', DashboardStatsView.as_view()), + path('user/', UserProfileView.as_view()), + path('user/phone/', UserPhoneView.as_view()), + path('user-coupons/', UserCouponsView.as_view()), + path('available-coupons/', AvailableCouponsView.as_view()), + path('user-projects/', UserProjectsView.as_view()), + path('user-honors/', UserHonorsView.as_view()), +] diff --git a/admin/server/apps/crm/views.py b/admin/server/apps/crm/views.py new file mode 100644 index 0000000..68a9200 --- /dev/null +++ b/admin/server/apps/crm/views.py @@ -0,0 +1,677 @@ +from rest_framework import viewsets, serializers +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly, IsAuthenticated +from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase +from .serializers import CategorySerializer, TeacherSerializer, TeachingCenterSerializer, ProjectSerializer, CouponSerializer, BannerSerializer, StudentSerializer, StudentCouponSerializer, StudentProjectSerializer, StudentHonorSerializer, StudentShowcaseSerializer +import requests +from django.db.models import Count, F +from django.utils import timezone +from datetime import timedelta, datetime + +class LoginView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + + def post(self, request): + code = request.data.get('code') + if not code: + return Response({'error': 'Code is required'}, status=status.HTTP_400_BAD_REQUEST) + + # Try to get real OpenID from WeChat API + from django.conf import settings + app_id = getattr(settings, 'WECHAT_APP_ID', None) + app_secret = getattr(settings, 'WECHAT_APP_SECRET', None) + + openid = None + + if app_id and app_secret: + try: + # Exchange code for session_key and openid + url = f"https://api.weixin.qq.com/sns/jscode2session?appid={app_id}&secret={app_secret}&js_code={code}&grant_type=authorization_code" + res = requests.get(url, timeout=5) + data = res.json() + + if 'openid' in data: + openid = data['openid'] + # We could also store session_key if needed + else: + print(f"WeChat Login Error: {data}") + except Exception as e: + print(f"WeChat Login Exception: {e}") + + # Fallback to mock if real login failed or not configured + if not openid: + # Mock OpenID based on code (or random if code is generic) + # Use code as a seed for consistent mock OpenID if possible, or just use it directly + # In a real scenario, code is one-time use, so we can't rely on it being consistent for the same user across logins + # unless we actually call WeChat API. + # For dev/mock purposes, let's assume 'mock_devtools' or similar fixed code means same user. + if code == 'mock_devtools': + openid = "mock_openid_devtools_user" + else: + # If we have AppID but failed to get OpenID, it might be an invalid code. + # But to keep development smooth if API fails, we fallback. + # To fix "new user every time" in dev without API access, we use a fixed mock ID + # IF we are in DEBUG mode. + if settings.DEBUG and not (app_id and app_secret): + openid = "mock_openid_fixed_dev_user" + else: + # If we have credentials but failed, or if we want distinct users per code: + openid = f"mock_openid_{code}" + + # Check if we already have a student with this openid + student, created = Student.objects.get_or_create( + openid=openid, + defaults={ + 'name': '微信用户', + 'wechat_nickname': '微信用户', + 'avatar': 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0' + } + ) + + # Ensure openid is saved if it was missing (e.g. if user was created via other means or if we just want to be sure) + if not student.openid: + student.openid = openid + student.save(update_fields=['openid']) + + serializer = StudentSerializer(student) + return Response({ + 'token': f"mock_token_{student.id}", # Mock token + 'user': serializer.data, + 'is_new_user': created + }) + +class UserPhoneView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + + def post(self, request): + code = request.data.get('code') + if not code: + return Response({'error': 'Code is required'}, status=status.HTTP_400_BAD_REQUEST) + + # Default mock phone number for dev + phone = "13800138000" + + # Try real implementation if configured in settings + # Add WECHAT_APP_ID and WECHAT_APP_SECRET to your settings.py or local_settings.py + from django.conf import settings + app_id = getattr(settings, 'WECHAT_APP_ID', None) + app_secret = getattr(settings, 'WECHAT_APP_SECRET', None) + + if app_id and app_secret: + try: + # 1. Get Access Token + token_url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={app_id}&secret={app_secret}" + token_res = requests.get(token_url, timeout=5).json() + access_token = token_res.get('access_token') + + if access_token: + # 2. Get Phone Number + # Note: The code from getPhoneNumber is valid for 5 mins and one-time use + phone_url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}" + phone_res = requests.post(phone_url, json={"code": code}, timeout=5).json() + + if phone_res.get('errcode') == 0: + phone_info = phone_res.get('phone_info') + phone = phone_info.get('phoneNumber') + else: + print(f"WeChat Phone API Error: {phone_res}") + else: + print(f"WeChat Token API Error: {token_res}") + except Exception as e: + print(f"WeChat API Exception: {e}") + + # Identify student + student = None + auth = request.headers.get('Authorization') + if auth and 'Bearer' in auth: + try: + # Extract ID from mock_token_{id} + token_str = auth.split(' ')[1] + if token_str.startswith('mock_token_'): + student_id = int(token_str.split('_')[-1]) + student = Student.objects.get(id=student_id) + except Exception as e: + print(f"Token parse error: {e}") + pass + + if not student: + student = Student.objects.first() + + if student: + student.phone = phone + # Also ensure openid is set if not present (mock behavior) + if not student.openid: + student.openid = f"mock_openid_phone_{phone}" + student.save() + + # 自动激活/发放注册优惠券 + + return Response({'phone': phone}) + +class DashboardStatsView(APIView): + """ + API for Dashboard Statistics + """ + permission_classes = [AllowAny] + + def get(self, request): + # Query params + try: + range_days = int(request.query_params.get('range', '14')) + if range_days < 7: + range_days = 7 + if range_days > 30: + range_days = 30 + except Exception: + range_days = 14 + dimension = request.query_params.get('dimension', 'organization') + # 1. Panel Counts + student_count = Student.objects.count() + organizations_count = Teacher.objects.count() + project_count = Project.objects.count() + # Count assigned coupons (StudentCoupon) + issued_coupon_count = StudentCoupon.objects.count() + # Active resources + active_banner_count = Banner.objects.filter(is_active=True).count() + active_showcase_count = StudentShowcase.objects.filter(is_active=True).count() + active_project_count = Project.objects.filter(is_active=True).count() + + # 2. Pie Chart: Project Types Distribution + # Group by project_type + project_types_data = Project.objects.values('project_type').annotate(count=Count('id')) + + # Map type codes to display names + type_mapping = dict(Project.PROJECT_TYPE_CHOICES) + pie_chart_data = [] + for item in project_types_data: + type_code = item['project_type'] + name = type_mapping.get(type_code, type_code) + pie_chart_data.append({ + 'type': type_code, + 'name': name, + 'value': item['count'] + }) + + # If empty, provide some defaults to avoid empty chart + if not pie_chart_data: + pie_chart_data = [ + {'name': '小主持语言培训', 'value': 0}, + {'name': '赛事管理', 'value': 0}, + {'name': '考级管理', 'value': 0} + ] + + # 3. Bar Chart: Popular dimension (organization/project) + if dimension == 'project': + popular_projects = Project.objects.annotate(enroll_num=Count('enrollments')).order_by('-enroll_num')[:7] + bar_names = [p.title for p in popular_projects] + bar_counts = [p.enroll_num for p in popular_projects] + bar_title = '热门项目' + else: + popular_organizations = Teacher.objects.annotate(student_num=Count('students')).order_by('-student_num')[:7] + bar_names = [t.name for t in popular_organizations] + bar_counts = [t.student_num for t in popular_organizations] + bar_title = '热门机构' + + # 4. Line Chart: 14-Day Trends + today = timezone.now().date() + days = [today - timedelta(days=i) for i in range(range_days - 1, -1, -1)] + + def daily_counts(model, date_field): + counts = [] + for d in days: + start = timezone.make_aware(datetime.combine(d, datetime.min.time())) + end = timezone.make_aware(datetime.combine(d, datetime.max.time())) + counts.append(model.objects.filter(**{f"{date_field}__range": (start, end)}).count()) + return counts + + student_daily = daily_counts(Student, 'created_at') + enroll_daily = daily_counts(StudentProject, 'enrolled_at') + coupon_assign_daily = daily_counts(StudentCoupon, 'assigned_at') + x_axis = [d.strftime('%m-%d') for d in days] + + line_chart_data = { + 'students': { + 'expectedData': student_daily, + 'actualData': student_daily, + 'xAxis': x_axis + }, + 'organizations': { + 'expectedData': [organizations_count] * len(days), + 'actualData': [organizations_count] * len(days), + 'xAxis': x_axis + }, + 'projects': { + 'expectedData': enroll_daily, + 'actualData': enroll_daily, + 'xAxis': x_axis + }, + 'coupons': { + 'expectedData': coupon_assign_daily, + 'actualData': coupon_assign_daily, + 'xAxis': x_axis + } + } + + # 5. Coupon Status Pie Chart + coupon_status_counts = StudentCoupon.objects.values('status').annotate(count=Count('id')) + status_mapping = dict(StudentCoupon.STATUS_CHOICES) + coupon_pie_chart_data = [ + {'status': item['status'], 'name': status_mapping.get(item['status'], item['status']), 'value': item['count']} + for item in coupon_status_counts + ] + + return Response({ + 'panel_data': { + 'students': student_count, + 'organizations': organizations_count, + 'projects': project_count, + 'projects_active': active_project_count, + 'coupons': issued_coupon_count, + 'banners_active': active_banner_count, + 'showcases_active': active_showcase_count + }, + 'pie_chart_data': pie_chart_data, + 'coupon_pie_chart_data': coupon_pie_chart_data, + 'bar_chart_data': { + 'title': bar_title, + 'names': bar_names, + 'counts': bar_counts + }, + 'line_chart_data': line_chart_data + }) + +class CategoryViewSet(viewsets.ModelViewSet): + queryset = Category.objects.all() + serializer_class = CategorySerializer + permission_classes = [AllowAny] + pagination_class = None # Return all categories without pagination for the app + search_fields = ['name'] + +class TeacherViewSet(viewsets.ModelViewSet): + queryset = Teacher.objects.all() + serializer_class = TeacherSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + pagination_class = None # Usually few teachers, return all for selects + search_fields = ['name', 'phone'] + +class TeachingCenterViewSet(viewsets.ModelViewSet): + queryset = TeachingCenter.objects.all() + serializer_class = TeachingCenterSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + pagination_class = None + search_fields = ['name', 'phone'] + +class ProjectViewSet(viewsets.ModelViewSet): + # queryset = Project.objects.select_related('category').all() + queryset = Project.objects.select_related('teacher').all() + serializer_class = ProjectSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + # search_fields = ['title', 'category__name'] + search_fields = ['title', 'teacher__name'] + # filterset_fields = ['category', 'project_type'] + filterset_fields = ['teacher', 'project_type'] + ordering_fields = ['sort_order', 'is_top', 'created_at', 'students', 'price'] + ordering = ['-is_top', 'sort_order', '-created_at'] + + def get_queryset(self): + queryset = super().get_queryset() + # Admin users (staff) can see all projects + if self.request.user.is_staff: + return queryset + # Public/Students can only see active projects + return queryset.filter(is_active=True) + +class CouponViewSet(viewsets.ModelViewSet): + queryset = Coupon.objects.all() + serializer_class = CouponSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + search_fields = ['title'] + filterset_fields = ['status'] + + @action(detail=True, methods=['post'], url_path='distribute') + def distribute(self, request, pk=None): + coupon = self.get_object() + student_ids = request.data.get('student_ids', []) + if not student_ids: + return Response({'error': '未选择学员'}, status=status.HTTP_400_BAD_REQUEST) + + if coupon.status == 'expired': + return Response({'error': '该优惠券已过期'}, status=status.HTTP_400_BAD_REQUEST) + + students = Student.objects.filter(id__in=student_ids) + created_count = 0 + for student in students: + StudentCoupon.objects.create( + student=student, + coupon=coupon, + status='assigned' + ) + created_count += 1 + + return Response({'message': f'成功分发给 {created_count} 位学员'}, status=status.HTTP_200_OK) + +class BannerViewSet(viewsets.ModelViewSet): + queryset = Banner.objects.all() + serializer_class = BannerSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + +class StudentViewSet(viewsets.ModelViewSet): + queryset = Student.objects.prefetch_related('enrollments__project').all() + serializer_class = StudentSerializer + permission_classes = [IsAuthenticated] + search_fields = ['name', 'phone'] + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_200_OK) + + @action(detail=True, methods=['post'], url_path='assign_coupon') + def assign_coupon(self, request, pk=None): + student = self.get_object() + coupon_id = request.data.get('coupon_id') + if not coupon_id: + return Response({'error': '未提供优惠券ID'}, status=status.HTTP_400_BAD_REQUEST) + + try: + coupon = Coupon.objects.get(id=coupon_id) + except Coupon.DoesNotExist: + return Response({'error': '优惠券不存在'}, status=status.HTTP_404_NOT_FOUND) + + if coupon.status == 'expired': + return Response({'error': '该优惠券已过期'}, status=status.HTTP_400_BAD_REQUEST) + + # Create assignment + assignment = StudentCoupon.objects.create( + student=student, + coupon=coupon, + status='assigned' + ) + + return Response(StudentCouponSerializer(assignment).data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['delete'], url_path='clear') + def clear(self, request): + Student.objects.all().delete() + return Response(status=status.HTTP_204_NO_CONTENT) + +class StudentCouponViewSet(viewsets.ModelViewSet): + queryset = StudentCoupon.objects.all() + serializer_class = StudentCouponSerializer + permission_classes = [IsAuthenticated] + filterset_fields = ['student', 'status'] + + def perform_update(self, serializer): + instance = serializer.instance + # Check if status is being changed to 'used' + new_status = serializer.validated_data.get('status') + if new_status == 'used' and instance.status != 'used': + serializer.save(used_at=timezone.now()) + else: + serializer.save() + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_200_OK) + +from django.db import transaction + +class StudentProjectViewSet(viewsets.ModelViewSet): + queryset = StudentProject.objects.all() + serializer_class = StudentProjectSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + filterset_fields = ['student', 'status', 'project'] + + @transaction.atomic + def create(self, request, *args, **kwargs): + # 1. Check for re-enrollment (Friendly Error) + student_id = request.data.get('student') + project_id = request.data.get('project') + + if student_id and project_id: + if StudentProject.objects.filter(student_id=student_id, project_id=project_id).exists(): + return Response( + {'detail': '您已报名该项目,请勿重复报名'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 2. Create Enrollment + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + + # Increment project student count + Project.objects.filter(id=project_id).update(students=F('students') + 1) + + # 3. Handle Coupon Redemption + coupon_id = request.data.get('coupon_id') + if coupon_id: + try: + # Lock the coupon row to prevent race conditions + # Use filter to be safe against MultipleObjectsReturned (though id should be unique) + scs = StudentCoupon.objects.select_for_update().filter(id=coupon_id, student_id=student_id) + if not scs.exists(): + raise serializers.ValidationError({'coupon_id': '优惠券不存在'}) + + # Take the first one if multiple exist (defensive programming) + sc = scs.first() + + if sc.status != 'assigned': + # Rollback if coupon is invalid + raise serializers.ValidationError({'coupon_id': '该优惠券不可用'}) + + sc.status = 'used' + sc.used_at = timezone.now() + sc.save() + + except Exception as e: + if isinstance(e, serializers.ValidationError): + raise e + print(f"Coupon redemption error: {e}") + raise serializers.ValidationError({'coupon_id': '优惠券处理失败'}) + + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_destroy(self, instance): + project_id = instance.project_id + super().perform_destroy(instance) + # Decrement project student count + Project.objects.filter(id=project_id).update(students=F('students') - 1) + +class StudentHonorViewSet(viewsets.ModelViewSet): + queryset = StudentHonor.objects.all() + serializer_class = StudentHonorSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + filterset_fields = ['student'] + +class StudentShowcaseViewSet(viewsets.ModelViewSet): + queryset = StudentShowcase.objects.all() + serializer_class = StudentShowcaseSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + filterset_fields = ['student'] + + def get_queryset(self): + queryset = super().get_queryset() + # Admin users (staff) can see all showcases + if self.request.user.is_staff: + return queryset + # Public/Students can only see active showcases + # Check if specific query param 'is_active' is passed, or default to active only for public + # Actually, standard behavior: filter active=True for public + return queryset.filter(is_active=True) + +class UserProfileView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + + def get_student_from_token(self, request): + from .models import Student + student = None + auth = request.headers.get('Authorization') + if auth and 'Bearer' in auth: + try: + token_str = auth.split(' ')[1] + if token_str.startswith('mock_token_'): + student_id = int(token_str.split('_')[-1]) + student = Student.objects.get(id=student_id) + except Exception: + pass + return student + + def get(self, request): + student = self.get_student_from_token(request) + if not student: + return Response({'error': 'Not authenticated'}, status=status.HTTP_401_UNAUTHORIZED) + + serializer = StudentSerializer(student) + return Response(serializer.data) + + def post(self, request): + student = self.get_student_from_token(request) + if not student: + return Response({'error': 'Not authenticated'}, status=status.HTTP_401_UNAUTHORIZED) + + payload = request.data or {} + fields = ['name', 'phone', 'age', 'company_name', 'position', 'address', 'wechat_nickname', 'avatar'] + for f in fields: + if f in payload: + setattr(student, f, payload.get(f)) + + # Ensure OpenID is preserved or set if missing + if not student.openid and 'openid' in payload: + student.openid = payload['openid'] + + student.save() + return Response(StudentSerializer(student).data) + +class UserCouponsView(APIView): + permission_classes = [AllowAny] + + def get_student_from_token(self, request): + from .models import Student + student = None + auth = request.headers.get('Authorization') + if auth and 'Bearer' in auth: + try: + token_str = auth.split(' ')[1] + if token_str.startswith('mock_token_'): + student_id = int(token_str.split('_')[-1]) + student = Student.objects.get(id=student_id) + except Exception: + pass + return student + + def get(self, request): + student = self.get_student_from_token(request) + if not student: + return Response([]) + + coupons = StudentCoupon.objects.filter(student=student).exclude(status='revoked') + serializer = StudentCouponSerializer(coupons, many=True) + return Response(serializer.data) + + def post(self, request): + student = self.get_student_from_token(request) + if not student: + return Response({'error': '请先登录'}, status=status.HTTP_401_UNAUTHORIZED) + + coupon_id = request.data.get('coupon_id') + if not coupon_id: + return Response({'error': '参数错误'}, status=status.HTTP_400_BAD_REQUEST) + + try: + coupon = Coupon.objects.get(id=coupon_id) + except Coupon.DoesNotExist: + return Response({'error': '优惠券不存在'}, status=status.HTTP_404_NOT_FOUND) + + # Check availability + if coupon.status != 'available': + return Response({'error': '优惠券不可领取'}, status=status.HTTP_400_BAD_REQUEST) + + if coupon.expiry < timezone.now().date(): + return Response({'error': '优惠券已过期'}, status=status.HTTP_400_BAD_REQUEST) + + # Check if already claimed + if StudentCoupon.objects.filter(student=student, coupon=coupon).exists(): + return Response({'error': '您已领取过该优惠券'}, status=status.HTTP_400_BAD_REQUEST) + + # Claim + StudentCoupon.objects.create( + student=student, + coupon=coupon, + status='assigned' + ) + + return Response({'message': '领取成功'}, status=status.HTTP_201_CREATED) + +class AvailableCouponsView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + # Get all available coupons with issue_type='project' (publicly visible) + # Also check expiry + today = timezone.now().date() + coupons = Coupon.objects.filter( + status='available', + expiry__gte=today, + issue_type='project' + ) + serializer = CouponSerializer(coupons, many=True) + return Response(serializer.data) + +class UserProjectsView(APIView): + permission_classes = [AllowAny] + + def get_student_from_token(self, request): + from .models import Student + student = None + auth = request.headers.get('Authorization') + if auth and 'Bearer' in auth: + try: + token_str = auth.split(' ')[1] + if token_str.startswith('mock_token_'): + student_id = int(token_str.split('_')[-1]) + student = Student.objects.get(id=student_id) + except Exception: + pass + return student + + def get(self, request): + student = self.get_student_from_token(request) + if not student: + return Response([]) + projects = StudentProject.objects.filter(student=student) + serializer = StudentProjectSerializer(projects, many=True) + return Response(serializer.data) + +class UserHonorsView(APIView): + permission_classes = [AllowAny] + + def get_student_from_token(self, request): + from .models import Student + student = None + auth = request.headers.get('Authorization') + if auth and 'Bearer' in auth: + try: + token_str = auth.split(' ')[1] + if token_str.startswith('mock_token_'): + student_id = int(token_str.split('_')[-1]) + student = Student.objects.get(id=student_id) + except Exception: + pass + return student + + def get(self, request): + student = self.get_student_from_token(request) + if not student: + return Response([]) + honors = StudentHonor.objects.filter(student=student) + serializer = StudentHonorSerializer(honors, many=True) + return Response(serializer.data) diff --git a/admin/server/apps/monitor/__init__.py b/admin/server/apps/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/monitor/admin.py b/admin/server/apps/monitor/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/admin/server/apps/monitor/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/admin/server/apps/monitor/apps.py b/admin/server/apps/monitor/apps.py new file mode 100644 index 0000000..e49ebca --- /dev/null +++ b/admin/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/admin/server/apps/monitor/middleware.py b/admin/server/apps/monitor/middleware.py new file mode 100644 index 0000000..038a432 --- /dev/null +++ b/admin/server/apps/monitor/middleware.py @@ -0,0 +1 @@ +from django.utils.deprecation import MiddlewareMixin diff --git a/admin/server/apps/monitor/migrations/__init__.py b/admin/server/apps/monitor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/monitor/models.py b/admin/server/apps/monitor/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/admin/server/apps/monitor/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/admin/server/apps/monitor/tests.py b/admin/server/apps/monitor/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/admin/server/apps/monitor/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/admin/server/apps/monitor/urls.py b/admin/server/apps/monitor/urls.py new file mode 100644 index 0000000..73ae248 --- /dev/null +++ b/admin/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/admin/server/apps/monitor/views.py b/admin/server/apps/monitor/views.py new file mode 100644 index 0000000..3a0cbca --- /dev/null +++ b/admin/server/apps/monitor/views.py @@ -0,0 +1,93 @@ +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 +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +# 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) + +def get_file_list(file_path): + dir_list = os.listdir(file_path) + if not dir_list: + return + else: + # 注意,这里使用lambda表达式,将文件按照最后修改时间顺序升序排列 + # os.path.getmtime() 函数是获取文件最后修改时间 + # os.path.getctime() 函数是获取文件最后创建时间 + dir_list = sorted(dir_list,key=lambda x: os.path.getmtime(os.path.join(file_path, x)), reverse=True) + # print(dir_list) + return dir_list + +class LogView(APIView): + + @swagger_auto_schema(manual_parameters=[ + openapi.Parameter('name', openapi.IN_QUERY, description='日志文件名', type=openapi.TYPE_STRING) + ]) + def get(self, request, *args, **kwargs): + """ + 查看最近的日志列表 + :query name + """ + logs =[] + name = request.GET.get('name', None) + # for root, dirs, files in os.walk(settings.LOG_PATH): + # files.reverse() + for file in get_file_list(settings.LOG_PATH): + if len(logs)>50:break + filepath = os.path.join(settings.LOG_PATH, file) + if name: + if name in filepath: + fsize = os.path.getsize(filepath) + if fsize: + logs.append({ + "name":file, + "filepath":filepath, + "size":round(fsize/1000,1) + }) + else: + 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/admin/server/apps/system/__init__.py b/admin/server/apps/system/__init__.py new file mode 100644 index 0000000..f5317b9 --- /dev/null +++ b/admin/server/apps/system/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.system.apps.SystemConfig' \ No newline at end of file diff --git a/admin/server/apps/system/admin.py b/admin/server/apps/system/admin.py new file mode 100644 index 0000000..e47b0d7 --- /dev/null +++ b/admin/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/admin/server/apps/system/apps.py b/admin/server/apps/system/apps.py new file mode 100644 index 0000000..dfd43d1 --- /dev/null +++ b/admin/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/admin/server/apps/system/authentication.py b/admin/server/apps/system/authentication.py new file mode 100644 index 0000000..3c85943 --- /dev/null +++ b/admin/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/admin/server/apps/system/filters.py b/admin/server/apps/system/filters.py new file mode 100644 index 0000000..4199790 --- /dev/null +++ b/admin/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/admin/server/apps/system/migrations/0001_initial.py b/admin/server/apps/system/migrations/0001_initial.py new file mode 100644 index 0000000..a53a840 --- /dev/null +++ b/admin/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/admin/server/apps/system/migrations/0002_auto_20210718_0918.py b/admin/server/apps/system/migrations/0002_auto_20210718_0918.py new file mode 100644 index 0000000..eaaaa6c --- /dev/null +++ b/admin/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/admin/server/apps/system/migrations/0003_auto_20220227_1732.py b/admin/server/apps/system/migrations/0003_auto_20220227_1732.py new file mode 100644 index 0000000..89a4c54 --- /dev/null +++ b/admin/server/apps/system/migrations/0003_auto_20220227_1732.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.8 on 2022-02-27 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0002_auto_20210718_0918'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='depts', + field=models.ManyToManyField(blank=True, related_name='roles', to='system.Organization', verbose_name='权限范围'), + ), + ] diff --git a/admin/server/apps/system/migrations/0004_auto_20251128_1413.py b/admin/server/apps/system/migrations/0004_auto_20251128_1413.py new file mode 100644 index 0000000..d85cccf --- /dev/null +++ b/admin/server/apps/system/migrations/0004_auto_20251128_1413.py @@ -0,0 +1,58 @@ +# Generated by Django 3.2.23 on 2025-11-28 06:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0003_auto_20220227_1732'), + ] + + operations = [ + migrations.AlterField( + model_name='dict', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='dicttype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='file', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='historicaldict', + name='id', + field=models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID'), + ), + migrations.AlterField( + model_name='organization', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='permission', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='position', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='user', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/admin/server/apps/system/migrations/0005_alter_historicaldict_options_alter_file_create_by_and_more.py b/admin/server/apps/system/migrations/0005_alter_historicaldict_options_alter_file_create_by_and_more.py new file mode 100644 index 0000000..52a6f6a --- /dev/null +++ b/admin/server/apps/system/migrations/0005_alter_historicaldict_options_alter_file_create_by_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.26 on 2025-11-29 13:57 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0004_auto_20251128_1413'), + ] + + operations = [ + migrations.AlterModelOptions( + name='historicaldict', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical 字典', 'verbose_name_plural': 'historical 字典'}, + ), + migrations.AlterField( + model_name='file', + name='create_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'), + ), + migrations.AlterField( + model_name='file', + name='update_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'), + ), + migrations.AlterField( + model_name='historicaldict', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/admin/server/apps/system/migrations/0006_auto_20251202_1204.py b/admin/server/apps/system/migrations/0006_auto_20251202_1204.py new file mode 100644 index 0000000..42a1e3e --- /dev/null +++ b/admin/server/apps/system/migrations/0006_auto_20251202_1204.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.23 on 2025-12-02 04:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0005_alter_historicaldict_options_alter_file_create_by_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='historicaldict', + options={'get_latest_by': 'history_date', 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical 字典'}, + ), + migrations.AlterField( + model_name='historicaldict', + name='history_date', + field=models.DateTimeField(), + ), + ] diff --git a/admin/server/apps/system/migrations/__init__.py b/admin/server/apps/system/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/system/mixins.py b/admin/server/apps/system/mixins.py new file mode 100644 index 0000000..b1a70c0 --- /dev/null +++ b/admin/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/admin/server/apps/system/models.py b/admin/server/apps/system/models.py new file mode 100644 index 0000000..d41ae4b --- /dev/null +++ b/admin/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='权限范围', related_name='roles') + 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/admin/server/apps/system/permission.py b/admin/server/apps/system/permission.py new file mode 100644 index 0000000..a7d961a --- /dev/null +++ b/admin/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 Organization, 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: + depts = Organization.objects.filter(roles__in = roles) + if obj.belong_dept not in 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/admin/server/apps/system/permission_data.py b/admin/server/apps/system/permission_data.py new file mode 100644 index 0000000..180a50b --- /dev/null +++ b/admin/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 apps.system.models import Organization +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: + queryset = queryset.filter(belong_dept__roles__in=roles) + 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/admin/server/apps/system/serializers.py b/admin/server/apps/system/serializers.py new file mode 100644 index 0000000..c422d08 --- /dev/null +++ b/admin/server/apps/system/serializers.py @@ -0,0 +1,187 @@ +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 UserSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'name'] + +class UserListSerializer(serializers.ModelSerializer): + """ + 用户列表序列化 + """ + dept_name = serializers.StringRelatedField(source='dept') + roles_name = serializers.StringRelatedField(source='roles', many=True) + avatar = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ['id', 'name', 'phone', 'email', 'position', + 'username', 'is_active', 'date_joined', 'dept_name', 'dept', 'roles', 'avatar', 'roles_name'] + + def get_avatar(self, obj): + if obj.avatar and obj.avatar.startswith('/'): + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.avatar) + return obj.avatar + + @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, required=False, allow_null=True) + + class Meta: + model = User + fields = ['id', 'username', 'name', 'phone', 'email', 'dept', + 'position', 'avatar', 'is_active', 'roles', 'is_superuser'] + + def validate_phone(self, phone): + if phone is not None: + 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, required=False) + + 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/admin/server/apps/system/signals.py b/admin/server/apps/system/signals.py new file mode 100644 index 0000000..334bc57 --- /dev/null +++ b/admin/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/admin/server/apps/system/tasks.py b/admin/server/apps/system/tasks.py new file mode 100644 index 0000000..6d2adae --- /dev/null +++ b/admin/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/admin/server/apps/system/tests.py b/admin/server/apps/system/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/admin/server/apps/system/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/admin/server/apps/system/urls.py b/admin/server/apps/system/urls.py new file mode 100644 index 0000000..02a4ecb --- /dev/null +++ b/admin/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/admin/server/apps/system/views.py b/admin/server/apps/system/views.py new file mode 100644 index 0000000..bf4f07d --- /dev/null +++ b/admin/server/apps/system/views.py @@ -0,0 +1,440 @@ +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, AllowAny +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 PIL import Image +from io import BytesIO +from django.core.files.uploadedfile import InMemoryUploadedFile +import sys +import os + +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): + 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 = ['sort', '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): + """ + 用户管理-增删改查 + """ + 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 perform_destroy(self, instance): + if instance.is_superuser: + raise ParseError('不能删除超级用户') + instance.delete() + + 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 == 'update': + return UserModifySerializer + elif self.action == 'list': + return UserListSerializer + return super().get_serializer_class() + + 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': request.build_absolute_uri(user.avatar) if user.avatar and user.avatar.startswith('/') else user.avatar, + 'perms': perms, + } + return Response(data) + +class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet): + """ + 文件上传用 + """ + perms_map = None + authentication_classes = [] + permission_classes=[AllowAny] + 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 = '图片' + # Image compression and resizing logic + try: + img = Image.open(fileobj) + + # Max dimensions (e.g., 1920x1920) to prevent huge images + MAX_WIDTH = 1920 + MAX_HEIGHT = 1920 + + if img.width > MAX_WIDTH or img.height > MAX_HEIGHT: + img.thumbnail((MAX_WIDTH, MAX_HEIGHT), Image.LANCZOS) + + # Compress + output_io = BytesIO() + + # Determine format + img_format = img.format + if not img_format: + img_format = 'JPEG' if 'jpeg' in mime or 'jpg' in mime else 'PNG' + + # For JPEG, we can use quality + if img_format.upper() in ('JPEG', 'JPG'): + if img.mode != 'RGB': + img = img.convert('RGB') + img.save(output_io, format='JPEG', quality=80, optimize=True) + new_mime = 'image/jpeg' + # Update extension if needed + root, ext = os.path.splitext(name) + if ext.lower() not in ['.jpg', '.jpeg']: + name = root + '.jpg' + elif img_format.upper() == 'PNG': + # PNG compression + img.save(output_io, format='PNG', optimize=True) + new_mime = 'image/png' + else: + # Fallback for other formats + img.save(output_io, format=img_format) + new_mime = mime + + output_io.seek(0) + new_size = output_io.getbuffer().nbytes + + # Create a new InMemoryUploadedFile + fileobj = InMemoryUploadedFile( + output_io, + 'file', + name, + new_mime, + new_size, + None + ) + size = new_size + mime = new_mime + + except Exception as e: + logger.error(f"Image compression failed: {e}") + # Fallback to original file if compression fails + fileobj.seek(0) + pass + + elif 'video' in mime: + type = '视频' + elif 'audio' in mime: + type = '音频' + elif 'application' or 'text' in mime: + type = '文档' + user = self.request.user if (self.request.user and self.request.user.is_authenticated) else None + if user and not isinstance(user, User): + user = None + instance = serializer.save(create_by = user, name=name, size=size, type=type, mime=mime, file=fileobj) + + # Ensure forward slashes for URL + file_name = instance.file.name.replace('\\', '/') + instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name) + + logger.info(f"File uploaded: {instance.path}") + instance.save() + + def perform_destroy(self, instance): + instance.file.delete(save=False) + instance.delete(soft=False) + + @action(methods=['delete'], detail=False) + def clear_all(self, request): + queryset = self.filter_queryset(self.get_queryset()) + for file_obj in queryset: + file_obj.file.delete(save=False) + file_obj.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/admin/server/apps/wf/__init__.py b/admin/server/apps/wf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/wf/admin.py b/admin/server/apps/wf/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/admin/server/apps/wf/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/admin/server/apps/wf/apps.py b/admin/server/apps/wf/apps.py new file mode 100644 index 0000000..f0709ed --- /dev/null +++ b/admin/server/apps/wf/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + +class WfConfig(AppConfig): + name = 'apps.wf' + verbose_name = '工作流管理' + + diff --git a/admin/server/apps/wf/filters.py b/admin/server/apps/wf/filters.py new file mode 100644 index 0000000..02c9066 --- /dev/null +++ b/admin/server/apps/wf/filters.py @@ -0,0 +1,26 @@ +from django_filters import rest_framework as filters +from .models import Ticket +class TicketFilterSet(filters.FilterSet): + start_create = filters.DateFilter(field_name="create_time", lookup_expr='gte') + end_create = filters.DateFilter(field_name="create_time", lookup_expr='lte') + category = filters.ChoiceFilter(choices = Ticket.category_choices, method='filter_category') + + class Meta: + model = Ticket + fields = ['workflow', 'state', 'act_state', 'start_create', 'end_create', 'category'] + + def filter_category(self, queryset, name, value): + user=self.request.user + if value == 'owner': # 我的 + queryset = queryset.filter(create_by=user) + elif value == 'duty': # 待办 + queryset = queryset.filter(participant__contains=user.id).exclude(act_state__in=[Ticket.TICKET_ACT_STATE_FINISH, Ticket.TICKET_ACT_STATE_CLOSED]) + elif value == 'worked': # 处理过的 + queryset = queryset.filter(ticketflow_ticket__participant=user).exclude(create_by=user).order_by('-update_time').distinct() + elif value == 'cc': # 抄送我的 + queryset = queryset.filter(ticketflow_ticket__participant_cc__contains=user.id).exclude(create_by=user).order_by('-update_time').distinct() + elif value == 'all': + pass + else: + queryset = queryset.none() + return queryset \ No newline at end of file diff --git a/admin/server/apps/wf/migrations/0001_initial.py b/admin/server/apps/wf/migrations/0001_initial.py new file mode 100644 index 0000000..a7e9cba --- /dev/null +++ b/admin/server/apps/wf/migrations/0001_initial.py @@ -0,0 +1,177 @@ +# Generated by Django 3.1.8 on 2022-01-29 02:04 + +from django.conf import settings +from django.db import migrations, models +import jsonfield.fields +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('system', '0002_auto_20210718_0918'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='State', + 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=50, verbose_name='名称')), + ('is_hidden', models.BooleanField(default=False, help_text='设置为True时,获取工单步骤api中不显示此状态(当前处于此状态时除外)', verbose_name='是否隐藏')), + ('sort', models.IntegerField(default=0, help_text='用于工单步骤接口时,step上状态的顺序(因为存在网状情况,所以需要人为设定顺序),值越小越靠前', verbose_name='状态顺序')), + ('type', models.IntegerField(choices=[(0, '普通'), (1, '开始'), (2, '结束')], default=0, help_text='0.普通类型 1.初始状态(用于新建工单时,获取对应的字段必填及transition信息) 2.结束状态(此状态下的工单不得再处理,即没有对应的transition)', verbose_name='状态类型')), + ('enable_retreat', models.BooleanField(default=False, help_text='开启后允许工单创建人在此状态直接撤回工单到初始状态', verbose_name='允许撤回')), + ('participant_type', models.IntegerField(blank=True, choices=[(0, '无处理人'), (1, '个人'), (2, '多人'), (4, '角色'), (6, '脚本'), (7, '工单的字段'), (9, '代码获取')], default=1, help_text='0.无处理人,1.个人,2.多人,3.部门,4.角色,5.变量(支持工单创建人,创建人的leader),6.脚本,7.工单的字段内容(如表单中的"测试负责人",需要为用户名或者逗号隔开的多个用户名),8.父工单的字段内容。 初始状态请选择类型5,参与人填create_by', verbose_name='参与者类型')), + ('participant', jsonfield.fields.JSONField(blank=True, default=list, help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表\\部门id\\角色id\\变量(create_by,create_by_tl)\\脚本记录的id等,包含子工作流的需要设置处理人为loonrobot', verbose_name='参与者')), + ('state_fields', jsonfield.fields.JSONField(default=dict, help_text='json格式字典存储,包括读写属性1:只读,2:必填,3:可选, 4:隐藏 示例:{"create_time":1,"title":2, "sn":1}, 内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称),state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称', verbose_name='表单字段')), + ('distribute_type', models.IntegerField(choices=[(1, '主动接单'), (2, '直接处理'), (3, '随机分配'), (4, '全部处理')], default=1, help_text='1.主动接单(如果当前处理人实际为多人的时候,需要先接单才能处理) 2.直接处理(即使当前处理人实际为多人,也可以直接处理) 3.随机分配(如果实际为多人,则系统会随机分配给其中一个人) 4.全部处理(要求所有参与人都要处理一遍,才能进入下一步)', verbose_name='分配方式')), + ('filter_policy', models.IntegerField(choices=[(0, '无'), (1, '和工单同属一及上级部门'), (2, '和创建人同属一及上级部门'), (3, '和上步处理人同属一及上级部门')], default=0, verbose_name='参与人过滤策略')), + ('participant_cc', jsonfield.fields.JSONField(blank=True, default=list, help_text='抄送给(userid列表)', verbose_name='抄送给')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_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='state_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Ticket', + 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='删除标记')), + ('title', models.CharField(blank=True, help_text='工单标题', max_length=500, null=True, verbose_name='标题')), + ('sn', models.CharField(help_text='工单的流水号', max_length=25, verbose_name='流水号')), + ('ticket_data', jsonfield.fields.JSONField(default=dict, help_text='工单自定义字段内容', verbose_name='工单数据')), + ('in_add_node', models.BooleanField(default=False, help_text='是否处于加签状态下', verbose_name='加签状态中')), + ('script_run_last_result', models.BooleanField(default=True, verbose_name='脚本最后一次执行结果')), + ('participant_type', models.IntegerField(choices=[(0, '无处理人'), (1, '个人'), (2, '多人'), (4, '角色'), (6, '脚本'), (7, '工单的字段'), (9, '代码获取')], default=0, help_text='0.无处理人,1.个人,2.多人', verbose_name='当前处理人类型')), + ('participant', jsonfield.fields.JSONField(blank=True, default=list, help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表', verbose_name='当前处理人')), + ('act_state', models.IntegerField(choices=[(0, '草稿中'), (1, '进行中'), (2, '被退回'), (3, '被撤回'), (4, '已完成'), (5, '已关闭')], default=1, help_text='当前工单的进行状态', verbose_name='进行状态')), + ('multi_all_person', jsonfield.fields.JSONField(blank=True, default=dict, help_text='需要当前状态处理人全部处理时实际的处理结果,json格式', verbose_name='全部处理的结果')), + ('add_node_man', models.ForeignKey(blank=True, help_text='加签操作的人,工单当前处理人处理完成后会回到该处理人,当处于加签状态下才有效', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='加签人')), + ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_belong_dept', to='system.organization', verbose_name='所属部门')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='wf.ticket', verbose_name='父工单')), + ('parent_state', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticket_parent_state', to='wf.state', verbose_name='父工单状态')), + ('state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_state', to='wf.state', verbose_name='当前状态')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Workflow', + 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=50, verbose_name='名称')), + ('key', models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='工作流标识')), + ('sn_prefix', models.CharField(default='hb', max_length=50, verbose_name='流水号前缀')), + ('description', models.CharField(blank=True, max_length=200, null=True, verbose_name='描述')), + ('view_permission_check', models.BooleanField(default=True, help_text='开启后,只允许工单的关联人(创建人、曾经的处理人)有权限查看工单', verbose_name='查看权限校验')), + ('limit_expression', jsonfield.fields.JSONField(blank=True, default=dict, help_text='限制周期({"period":24} 24小时), 限制次数({"count":1}在限制周期内只允许提交1次), 限制级别({"level":1} 针对(1单个用户 2全局)限制周期限制次数,默认特定用户);允许特定人员提交({"allow_persons":"zhangsan,lisi"}只允许张三提交工单,{"allow_depts":"1,2"}只允许部门id为1和2的用户提交工单,{"allow_roles":"1,2"}只允许角色id为1和2的用户提交工单)', verbose_name='限制表达式')), + ('display_form_str', jsonfield.fields.JSONField(blank=True, default=list, help_text='默认"[]",用于用户只有对应工单查看权限时显示哪些字段,field_key的list的json,如["days","sn"],内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称),state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称', verbose_name='展现表单字段')), + ('title_template', models.CharField(blank=True, default='{title}', help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}', max_length=50, null=True, verbose_name='标题模板')), + ('content_template', models.CharField(blank=True, default='标题:{title}, 创建时间:{create_time}', help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}', max_length=1000, null=True, verbose_name='内容模板')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflow_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='workflow_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Transition', + 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=50, verbose_name='操作')), + ('timer', models.IntegerField(default=0, help_text='单位秒。处于源状态X秒后如果状态都没有过变化则自动流转到目标状态。设置时间有效', verbose_name='定时器(单位秒)')), + ('condition_expression', jsonfield.fields.JSONField(default=list, help_text='流转条件表达式,根据表达式中的条件来确定流转的下个状态,格式为[{"expression":"{days} > 3 and {days}<10", "target_state":11}] 其中{}用于填充工单的字段key,运算时会换算成实际的值,当符合条件下个状态将变为target_state_id中的值,表达式只支持简单的运算或datetime/time运算.loonflow会以首次匹配成功的条件为准,所以多个条件不要有冲突', max_length=1000, verbose_name='条件表达式')), + ('attribute_type', models.IntegerField(choices=[(1, '同意'), (2, '拒绝'), (3, '其他')], default=1, help_text='属性类型,1.同意,2.拒绝,3.其他', verbose_name='属性类型')), + ('field_require_check', models.BooleanField(default=True, help_text='默认在用户点击操作的时候需要校验工单表单的必填项,如果设置为否则不检查。用于如"退回"属性的操作,不需要填写表单内容', verbose_name='是否校验必填项')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transition_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('destination_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dstate_transition', to='wf.state', verbose_name='目的状态')), + ('source_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sstate_transition', to='wf.state', verbose_name='源状态')), + ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transition_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='所属工作流')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TicketFlow', + 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='删除标记')), + ('suggestion', models.CharField(blank=True, default='', max_length=10000, verbose_name='处理意见')), + ('participant_type', models.IntegerField(choices=[(0, '无处理人'), (1, '个人'), (2, '多人'), (4, '角色'), (6, '脚本'), (7, '工单的字段'), (9, '代码获取')], default=0, help_text='0.无处理人,1.个人,2.多人等', verbose_name='处理人类型')), + ('participant_str', models.CharField(blank=True, help_text='非人工处理的处理人相关信息', max_length=200, null=True, verbose_name='处理人')), + ('ticket_data', jsonfield.fields.JSONField(blank=True, default=dict, help_text='可以用于记录当前表单数据,json格式', verbose_name='工单数据')), + ('intervene_type', models.IntegerField(choices=[(0, '正常处理'), (1, '转交'), (2, '加签'), (3, '加签处理完成'), (4, '接单'), (5, '评论'), (6, '删除'), (7, '强制关闭'), (8, '强制修改状态'), (9, 'hook操作'), (10, '撤回'), (11, '抄送')], default=0, help_text='流转类型', verbose_name='干预类型')), + ('participant_cc', jsonfield.fields.JSONField(blank=True, default=list, help_text='抄送给(userid列表)', verbose_name='抄送给')), + ('participant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticketflow_participant', to=settings.AUTH_USER_MODEL, verbose_name='处理人')), + ('state', models.ForeignKey(blank=True, default=0, on_delete=django.db.models.deletion.CASCADE, to='wf.state', verbose_name='当前状态')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticketflow_ticket', to='wf.ticket', verbose_name='关联工单')), + ('transition', models.ForeignKey(blank=True, help_text='与worklow.Transition关联, 为空时表示认为干预的操作', null=True, on_delete=django.db.models.deletion.CASCADE, to='wf.transition', verbose_name='流转id')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='ticket', + name='workflow', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='关联工作流'), + ), + migrations.AddField( + model_name='state', + name='workflow', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='所属工作流'), + ), + migrations.CreateModel( + name='CustomField', + 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='删除标记')), + ('field_type', models.CharField(choices=[('string', '字符串'), ('int', '整型'), ('float', '浮点'), ('boolean', '布尔'), ('date', '日期'), ('datetime', '日期时间'), ('radio', '单选'), ('checkbox', '多选'), ('select', '单选下拉'), ('selects', '多选下拉'), ('cascader', '单选级联'), ('cascaders', '多选级联'), ('select_dg', '弹框单选'), ('select_dgs', '弹框多选'), ('textarea', '文本域'), ('file', '附件')], help_text='string, int, float, date, datetime, radio, checkbox, select, selects, cascader, cascaders, select_dg, select_dgs,textarea, file', max_length=50, verbose_name='类型')), + ('field_key', models.CharField(help_text='字段类型请尽量特殊,避免与系统中关键字冲突', max_length=50, verbose_name='字段标识')), + ('field_name', models.CharField(max_length=50, verbose_name='字段名称')), + ('sort', models.IntegerField(default=0, help_text='工单基础字段在表单中排序为:流水号0,标题20,状态id40,状态名41,创建人80,创建时间100,更新时间120.前端展示工单信息的表单可以根据这个id顺序排列', verbose_name='排序')), + ('default_value', models.CharField(blank=True, help_text='前端展示时,可以将此内容作为表单中的该字段的默认值', max_length=100, null=True, verbose_name='默认值')), + ('description', models.CharField(blank=True, help_text='字段的描述信息,可用于显示在字段的下方对该字段的详细描述', max_length=100, null=True, verbose_name='描述')), + ('placeholder', models.CharField(blank=True, help_text='用户工单详情表单中作为字段的占位符显示', max_length=100, null=True, verbose_name='占位符')), + ('field_template', models.TextField(blank=True, help_text='文本域类型字段前端显示时可以将此内容作为字段的placeholder', null=True, verbose_name='文本域模板')), + ('boolean_field_display', jsonfield.fields.JSONField(blank=True, default=dict, help_text='当为布尔类型时候,可以支持自定义显示形式。{"1":"是","0":"否"}或{"1":"需要","0":"不需要"},注意数字也需要引号', verbose_name='布尔类型显示名')), + ('field_choice', jsonfield.fields.JSONField(blank=True, default=list, help_text='选项值,格式为list, 例["id":1, "name":"张三"]', verbose_name='选项值')), + ('label', models.CharField(default='', help_text='处理特殊逻辑使用,比如sys_user用于获取用户作为选项', max_length=1000, verbose_name='标签')), + ('is_hidden', models.BooleanField(default=False, help_text='可用于携带不需要用户查看的字段信息', verbose_name='是否隐藏')), + ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customfield_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='customfield_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='wf.workflow', verbose_name='所属工作流')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/admin/server/apps/wf/migrations/__init__.py b/admin/server/apps/wf/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/apps/wf/models.py b/admin/server/apps/wf/models.py new file mode 100644 index 0000000..872b3f8 --- /dev/null +++ b/admin/server/apps/wf/models.py @@ -0,0 +1,237 @@ +from random import choice +from django.db import models +import jsonfield +from django.db.models.base import Model +import django.utils.timezone as timezone +from django.db.models.query import QuerySet +from apps.system.models import CommonAModel, CommonBModel, Organization, User, Dict, File +from utils.model import SoftModel, BaseModel +from simple_history.models import HistoricalRecords + + +class Workflow(CommonAModel): + """ + 工作流 + """ + name = models.CharField('名称', max_length=50) + key = models.CharField('工作流标识', unique=True, max_length=20, null=True, blank=True) + sn_prefix = models.CharField('流水号前缀', max_length=50, default='hb') + description = models.CharField('描述', max_length=200, null=True, blank=True) + view_permission_check = models.BooleanField('查看权限校验', default=True, help_text='开启后,只允许工单的关联人(创建人、曾经的处理人)有权限查看工单') + limit_expression = models.JSONField('限制表达式', default=dict, blank=True, help_text='限制周期({"period":24} 24小时), 限制次数({"count":1}在限制周期内只允许提交1次), 限制级别({"level":1} 针对(1单个用户 2全局)限制周期限制次数,默认特定用户);允许特定人员提交({"allow_persons":"zhangsan,lisi"}只允许张三提交工单,{"allow_depts":"1,2"}只允许部门id为1和2的用户提交工单,{"allow_roles":"1,2"}只允许角色id为1和2的用户提交工单)') + display_form_str = models.JSONField('展现表单字段', default=list, blank=True, help_text='默认"[]",用于用户只有对应工单查看权限时显示哪些字段,field_key的list的json,如["days","sn"],内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称),state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称') + title_template = models.CharField('标题模板', max_length=50, default='{title}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:你有一个待办工单:{title}') + content_template = models.CharField('内容模板', max_length=1000, default='标题:{title}, 创建时间:{create_time}', null=True, blank=True, help_text='工单字段的值可以作为参数写到模板中,格式如:标题:{title}, 创建时间:{create_time}') + +class State(CommonAModel): + """ + 状态记录 + """ + STATE_TYPE_START = 1 + STATE_TYPE_END = 2 + type_choices = ( + (0, '普通'), + (STATE_TYPE_START, '开始'), + (STATE_TYPE_END, '结束') + ) + PARTICIPANT_TYPE_PERSONAL = 1 + PARTICIPANT_TYPE_MULTI = 2 + PARTICIPANT_TYPE_DEPT = 3 + PARTICIPANT_TYPE_ROLE = 4 + PARTICIPANT_TYPE_VARIABLE = 5 + PARTICIPANT_TYPE_ROBOT = 6 + PARTICIPANT_TYPE_FIELD = 7 + PARTICIPANT_TYPE_PARENT_FIELD = 8 + PARTICIPANT_TYPE_FORMCODE = 9 + state_participanttype_choices = ( + (0, '无处理人'), + (PARTICIPANT_TYPE_PERSONAL, '个人'), + (PARTICIPANT_TYPE_MULTI, '多人'), + # (PARTICIPANT_TYPE_DEPT, '部门'), + (PARTICIPANT_TYPE_ROLE, '角色'), + # (PARTICIPANT_TYPE_VARIABLE, '变量'), + (PARTICIPANT_TYPE_ROBOT, '脚本'), + (PARTICIPANT_TYPE_FIELD, '工单的字段'), + # (PARTICIPANT_TYPE_PARENT_FIELD, '父工单的字段'), + (PARTICIPANT_TYPE_FORMCODE, '代码获取') + ) + STATE_DISTRIBUTE_TYPE_ACTIVE = 1 # 主动接单 + STATE_DISTRIBUTE_TYPE_DIRECT = 2 # 直接处理(当前为多人的情况,都可以处理,而不需要先接单) + STATE_DISTRIBUTE_TYPE_RANDOM = 3 # 随机分配 + STATE_DISTRIBUTE_TYPE_ALL = 4 # 全部处理 + state_distribute_choices=( + (STATE_DISTRIBUTE_TYPE_ACTIVE, '主动接单'), + (STATE_DISTRIBUTE_TYPE_DIRECT, '直接处理'), + (STATE_DISTRIBUTE_TYPE_RANDOM, '随机分配'), + (STATE_DISTRIBUTE_TYPE_ALL, '全部处理'), + ) + + STATE_FIELD_READONLY= 1 # 字段只读 + STATE_FIELD_REQUIRED = 2 # 字段必填 + STATE_FIELD_OPTIONAL = 3 # 字段可选 + STATE_FIELD_HIDDEN = 4 # 字段隐藏 + state_filter_choices=( + (0, '无'), + (1, '和工单同属一及上级部门'), + (2, '和创建人同属一及上级部门'), + (3, '和上步处理人同属一及上级部门'), + ) + name = models.CharField('名称', max_length=50) + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='所属工作流') + is_hidden = models.BooleanField('是否隐藏', default=False, help_text='设置为True时,获取工单步骤api中不显示此状态(当前处于此状态时除外)') + sort = models.IntegerField('状态顺序', default=0, help_text='用于工单步骤接口时,step上状态的顺序(因为存在网状情况,所以需要人为设定顺序),值越小越靠前') + type = models.IntegerField('状态类型', default=0, choices=type_choices, help_text='0.普通类型 1.初始状态(用于新建工单时,获取对应的字段必填及transition信息) 2.结束状态(此状态下的工单不得再处理,即没有对应的transition)') + enable_retreat = models.BooleanField('允许撤回', default=False, help_text='开启后允许工单创建人在此状态直接撤回工单到初始状态') + participant_type = models.IntegerField('参与者类型', choices=state_participanttype_choices, default=1, blank=True, help_text='0.无处理人,1.个人,2.多人,3.部门,4.角色,5.变量(支持工单创建人,创建人的leader),6.脚本,7.工单的字段内容(如表单中的"测试负责人",需要为用户名或者逗号隔开的多个用户名),8.父工单的字段内容。 初始状态请选择类型5,参与人填create_by') + participant = models.JSONField('参与者', default=list, blank=True, help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表\部门id\角色id\变量(create_by,create_by_tl)\脚本记录的id等,包含子工作流的需要设置处理人为loonrobot') + state_fields = models.JSONField('表单字段', default=dict, help_text='json格式字典存储,包括读写属性1:只读,2:必填,3:可选, 4:隐藏 示例:{"create_time":1,"title":2, "sn":1}, 内置特殊字段participant_info.participant_name:当前处理人信息(部门名称、角色名称),state.state_name:当前状态的状态名,workflow.workflow_name:工作流名称') # json格式存储,包括读写属性1:只读,2:必填,3:可选,4:不显示, 字典的字典 + distribute_type = models.IntegerField('分配方式', default=1, choices=state_distribute_choices, help_text='1.主动接单(如果当前处理人实际为多人的时候,需要先接单才能处理) 2.直接处理(即使当前处理人实际为多人,也可以直接处理) 3.随机分配(如果实际为多人,则系统会随机分配给其中一个人) 4.全部处理(要求所有参与人都要处理一遍,才能进入下一步)') + filter_policy = models.IntegerField('参与人过滤策略', default=0, choices=state_filter_choices) + participant_cc = models.JSONField('抄送给', default=list, blank=True, help_text='抄送给(userid列表)') + +class Transition(CommonAModel): + """ + 工作流流转,定时器,条件(允许跳过), 条件流转与定时器不可同时存在 + """ + TRANSITION_ATTRIBUTE_TYPE_ACCEPT = 1 # 同意 + TRANSITION_ATTRIBUTE_TYPE_REFUSE = 2 # 拒绝 + TRANSITION_ATTRIBUTE_TYPE_OTHER = 3 # 其他 + attribute_type_choices = ( + (1, '同意'), + (2, '拒绝'), + (3, '其他') + ) + TRANSITION_INTERVENE_TYPE_DELIVER = 1 # 转交操作 + TRANSITION_INTERVENE_TYPE_ADD_NODE = 2 # 加签操作 + TRANSITION_INTERVENE_TYPE_ADD_NODE_END = 3 # 加签处理完成 + TRANSITION_INTERVENE_TYPE_ACCEPT = 4 # 接单操作 + TRANSITION_INTERVENE_TYPE_COMMENT = 5 # 评论操作 + TRANSITION_INTERVENE_TYPE_DELETE = 6 # 删除操作 + TRANSITION_INTERVENE_TYPE_CLOSE = 7 # 强制关闭操作 + TRANSITION_INTERVENE_TYPE_ALTER_STATE = 8 # 强制修改状态操作 + TRANSITION_INTERVENE_TYPE_HOOK = 9 # hook操作 + TRANSITION_INTERVENE_TYPE_RETREAT = 10 # 撤回 + TRANSITION_INTERVENE_TYPE_CC = 11 # 抄送 + + intervene_type_choices = ( + (0, '正常处理'), + (TRANSITION_INTERVENE_TYPE_DELIVER, '转交'), + (TRANSITION_INTERVENE_TYPE_ADD_NODE, '加签'), + (TRANSITION_INTERVENE_TYPE_ADD_NODE_END, '加签处理完成'), + (TRANSITION_INTERVENE_TYPE_ACCEPT, '接单'), + (TRANSITION_INTERVENE_TYPE_COMMENT, '评论'), + (TRANSITION_INTERVENE_TYPE_DELETE, '删除'), + (TRANSITION_INTERVENE_TYPE_CLOSE, '强制关闭'), + (TRANSITION_INTERVENE_TYPE_ALTER_STATE, '强制修改状态'), + (TRANSITION_INTERVENE_TYPE_HOOK, 'hook操作'), + (TRANSITION_INTERVENE_TYPE_RETREAT, '撤回'), + (TRANSITION_INTERVENE_TYPE_CC, '抄送') + ) + + name = models.CharField('操作', max_length=50) + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='所属工作流') + timer = models.IntegerField('定时器(单位秒)', default=0, help_text='单位秒。处于源状态X秒后如果状态都没有过变化则自动流转到目标状态。设置时间有效') + source_state = models.ForeignKey(State, on_delete=models.CASCADE, verbose_name='源状态', related_name='sstate_transition') + destination_state = models.ForeignKey(State, on_delete=models.CASCADE, verbose_name='目的状态', related_name='dstate_transition') + condition_expression = models.JSONField('条件表达式', max_length=1000, default=list, help_text='流转条件表达式,根据表达式中的条件来确定流转的下个状态,格式为[{"expression":"{days} > 3 and {days}<10", "target_state":11}] 其中{}用于填充工单的字段key,运算时会换算成实际的值,当符合条件下个状态将变为target_state_id中的值,表达式只支持简单的运算或datetime/time运算.loonflow会以首次匹配成功的条件为准,所以多个条件不要有冲突' ) + attribute_type = models.IntegerField('属性类型', default=1, choices=attribute_type_choices, help_text='属性类型,1.同意,2.拒绝,3.其他') + field_require_check = models.BooleanField('是否校验必填项', default=True, help_text='默认在用户点击操作的时候需要校验工单表单的必填项,如果设置为否则不检查。用于如"退回"属性的操作,不需要填写表单内容') + + +class CustomField(CommonAModel): + """自定义字段, 设定某个工作流有哪些自定义字段""" + field_type_choices = ( + ('string', '字符串'), + ('int', '整型'), + ('float', '浮点'), + ('boolean', '布尔'), + ('date', '日期'), + ('datetime', '日期时间'), + ('radio', '单选'), + ('checkbox', '多选'), + ('select', '单选下拉'), + ('selects', '多选下拉'), + ('cascader', '单选级联'), + ('cascaders', '多选级联'), + ('select_dg', '弹框单选'), + ('select_dgs', '弹框多选'), + ('textarea', '文本域'), + ('file', '附件') + ) + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='所属工作流') + field_type = models.CharField('类型', max_length=50, choices=field_type_choices, + help_text='string, int, float, date, datetime, radio, checkbox, select, selects, cascader, cascaders, select_dg, select_dgs,textarea, file') + field_key = models.CharField('字段标识', max_length=50, help_text='字段类型请尽量特殊,避免与系统中关键字冲突') + field_name = models.CharField('字段名称', max_length=50) + sort = models.IntegerField('排序', default=0, help_text='工单基础字段在表单中排序为:流水号0,标题20,状态id40,状态名41,创建人80,创建时间100,更新时间120.前端展示工单信息的表单可以根据这个id顺序排列') + default_value = models.CharField('默认值', null=True, blank=True, max_length=100, help_text='前端展示时,可以将此内容作为表单中的该字段的默认值') + description = models.CharField('描述', max_length=100, blank=True, null=True, help_text='字段的描述信息,可用于显示在字段的下方对该字段的详细描述') + placeholder = models.CharField('占位符', max_length=100, blank=True, null=True, help_text='用户工单详情表单中作为字段的占位符显示') + field_template = models.TextField('文本域模板', null=True, blank=True, help_text='文本域类型字段前端显示时可以将此内容作为字段的placeholder') + boolean_field_display = models.JSONField('布尔类型显示名', default=dict, blank=True, + help_text='当为布尔类型时候,可以支持自定义显示形式。{"1":"是","0":"否"}或{"1":"需要","0":"不需要"},注意数字也需要引号') + + field_choice = models.JSONField('选项值', default=list, blank=True, + help_text='选项值,格式为list, 例["id":1, "name":"张三"]') + + label = models.CharField('标签', max_length=1000, default='', help_text='处理特殊逻辑使用,比如sys_user用于获取用户作为选项') + # hook = models.CharField('hook', max_length=1000, default='', help_text='获取下拉选项用于动态选项值') + is_hidden = models.BooleanField('是否隐藏', default=False, help_text='可用于携带不需要用户查看的字段信息') + +class Ticket(CommonBModel): + """ + 工单 + """ + TICKET_ACT_STATE_DRAFT = 0 # 草稿中 + TICKET_ACT_STATE_ONGOING = 1 # 进行中 + TICKET_ACT_STATE_BACK = 2 # 被退回 + TICKET_ACT_STATE_RETREAT = 3 # 被撤回 + TICKET_ACT_STATE_FINISH = 4 # 已完成 + TICKET_ACT_STATE_CLOSED = 5 # 已关闭 + + act_state_choices =( + (TICKET_ACT_STATE_DRAFT, '草稿中'), + (TICKET_ACT_STATE_ONGOING, '进行中'), + (TICKET_ACT_STATE_BACK, '被退回'), + (TICKET_ACT_STATE_RETREAT, '被撤回'), + (TICKET_ACT_STATE_FINISH, '已完成'), + (TICKET_ACT_STATE_CLOSED, '已关闭') + ) + category_choices =( + ('all', '全部'), + ('owner', '我创建的'), + ('duty', '待办'), + ('worked', '我处理的'), + ('cc', '抄送我的') + ) + title = models.CharField('标题', max_length=500, null=True, blank=True, help_text="工单标题") + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, verbose_name='关联工作流') + sn = models.CharField('流水号', max_length=25, help_text="工单的流水号") + state = models.ForeignKey(State, on_delete=models.CASCADE, verbose_name='当前状态', related_name='ticket_state') + parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, verbose_name='父工单') + parent_state = models.ForeignKey(State, null=True, blank=True, on_delete=models.CASCADE, verbose_name='父工单状态', related_name='ticket_parent_state') + ticket_data = models.JSONField('工单数据', default=dict, help_text='工单自定义字段内容') + in_add_node = models.BooleanField('加签状态中', default=False, help_text='是否处于加签状态下') + add_node_man = models.ForeignKey(User, verbose_name='加签人', on_delete=models.SET_NULL, null=True, blank=True, help_text='加签操作的人,工单当前处理人处理完成后会回到该处理人,当处于加签状态下才有效') + script_run_last_result = models.BooleanField('脚本最后一次执行结果', default=True) + participant_type = models.IntegerField('当前处理人类型', default=0, help_text='0.无处理人,1.个人,2.多人', choices=State.state_participanttype_choices) + participant = models.JSONField('当前处理人', default=list, blank=True, help_text='可以为空(无处理人的情况,如结束状态)、userid、userid列表') + act_state = models.IntegerField('进行状态', default=1, help_text='当前工单的进行状态', choices=act_state_choices) + multi_all_person = models.JSONField('全部处理的结果', default=dict, blank=True, help_text='需要当前状态处理人全部处理时实际的处理结果,json格式') + + +class TicketFlow(BaseModel): + """ + 工单流转日志 + """ + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, verbose_name='关联工单', related_name='ticketflow_ticket') + transition = models.ForeignKey(Transition, verbose_name='流转id', help_text='与worklow.Transition关联, 为空时表示认为干预的操作', on_delete=models.CASCADE, null=True, blank=True) + suggestion = models.CharField('处理意见', max_length=10000, default='', blank=True) + participant_type = models.IntegerField('处理人类型', default=0, help_text='0.无处理人,1.个人,2.多人等', choices=State.state_participanttype_choices) + participant = models.ForeignKey(User, verbose_name='处理人', on_delete=models.SET_NULL, null=True, blank=True, related_name='ticketflow_participant') + participant_str = models.CharField('处理人', max_length=200, null=True, blank=True, help_text='非人工处理的处理人相关信息') + state = models.ForeignKey(State, verbose_name='当前状态', default=0, blank=True, on_delete=models.CASCADE) + ticket_data = models.JSONField('工单数据', default=dict, blank=True, help_text='可以用于记录当前表单数据,json格式') + intervene_type = models.IntegerField('干预类型', default=0, help_text='流转类型', choices=Transition.intervene_type_choices) + participant_cc = models.JSONField('抄送给', default=list, blank=True, help_text='抄送给(userid列表)') + diff --git a/admin/server/apps/wf/scripts.py b/admin/server/apps/wf/scripts.py new file mode 100644 index 0000000..eda2694 --- /dev/null +++ b/admin/server/apps/wf/scripts.py @@ -0,0 +1,56 @@ +from apps.system.models import User +from apps.wf.models import State, Ticket, TicketFlow, Transition + + +class GetParticipants: + """ + 获取处理人脚本 + """ + all_funcs = [ + {'func':'get_create_by', 'name':'获取工单创建人'} + ] + + # def all_funcs(self): + # # return list(filter(lambda x: x.startswith('get_') and callable(getattr(self, x)), dir(self))) + # return [(func, getattr(self, func).__doc__) for func in dir(self) if callable(getattr(self, func)) and func.startswith('get_')] + + @classmethod + def get_create_by(cls, state:dict={}, ticket:dict={}, new_ticket_data:dict={}, handler:User={}): + """工单创建人""" + participant = ticket.create_by.id + return participant + +class HandleScripts: + """ + 任务处理脚本 + """ + all_funcs = [ + {'func': 'handle_something', 'name':'处理一些工作'} + ] + + + @classmethod + def to_next(cls, ticket:Ticket, by_timer:bool=False, by_task:bool=False, by_hook:bool=False, script_str:str=''): + # 获取信息 + transition_obj = Transition.objects.filter(source_state=ticket.state, is_deleted=False).first() + + TicketFlow.objects.create(ticket=ticket, state=ticket.state, + participant_type=State.PARTICIPANT_TYPE_ROBOT, + participant_str='func:{}'.format(script_str), + transition=transition_obj) + from .services import WfService + + # 自动执行流转 + WfService.handle_ticket(ticket=ticket, transition=transition_obj, new_ticket_data=ticket.ticket_data, by_task=True) + + return ticket + + @classmethod + def handle_something(cls, ticket:Ticket): + """处理一些工作""" + # 任务处理代码区 + + + # 调用自动流转 + ticket = cls.to_next(ticket=ticket, by_task=True, script_str= 'handle_something') + diff --git a/admin/server/apps/wf/serializers.py b/admin/server/apps/wf/serializers.py new file mode 100644 index 0000000..70b03bc --- /dev/null +++ b/admin/server/apps/wf/serializers.py @@ -0,0 +1,193 @@ +from apps.system.models import Organization, User +from apps.system.serializers import UserSimpleSerializer +import rest_framework +from rest_framework import serializers + +from .models import State, Ticket, TicketFlow, Workflow, Transition, CustomField + + +class WorkflowSerializer(serializers.ModelSerializer): + class Meta: + model = Workflow + fields = '__all__' + +class StateSerializer(serializers.ModelSerializer): + class Meta: + model = State + fields = '__all__' + +class WorkflowSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = Workflow + fields = ['id', 'name'] + +class StateSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = State + fields = ['id', 'name', 'type', 'distribute_type', 'enable_retreat'] + +class TransitionSerializer(serializers.ModelSerializer): + source_state_ = StateSimpleSerializer(source='source_state', read_only=True) + destination_state_ = StateSimpleSerializer(source='destination_state', read_only=True) + class Meta: + model = Transition + fields = '__all__' + @staticmethod + def setup_eager_loading(queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.select_related('source_state','destination_state') + return queryset + +class AllField(serializers.Field): + def to_representation(self, value): + return value + + def to_internal_value(self, data): + return data + +class FieldChoiceSerializer(serializers.Serializer): + id = AllField(label='ID') + name = serializers.CharField(label='名称') + + +class CustomFieldSerializer(serializers.ModelSerializer): + class Meta: + model = CustomField + fields = '__all__' + +class CustomFieldCreateUpdateSerializer(serializers.ModelSerializer): + + field_choice = FieldChoiceSerializer(label='选项列表', many=True, required=False) + class Meta: + model = CustomField + fields = ['workflow', 'field_type', 'field_key', 'field_name', + 'sort', 'default_value', 'description', 'placeholder', 'field_template', + 'boolean_field_display', 'field_choice', 'label', 'is_hidden'] + + +class TicketSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = Ticket + fields = '__all__' + +class TicketCreateSerializer(serializers.ModelSerializer): + transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), write_only=True) + title = serializers.CharField(allow_blank=True, required=False) + class Meta: + model=Ticket + fields=['title','workflow', 'ticket_data', 'transition'] + + def create(self, validated_data): + return super().create(validated_data) + +class TicketSerializer(serializers.ModelSerializer): + workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + + class Meta: + model = Ticket + fields = '__all__' + + @staticmethod + def setup_eager_loading(queryset): + queryset = queryset.select_related('workflow','state') + return queryset + +class TicketListSerializer(serializers.ModelSerializer): + workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + + class Meta: + model = Ticket + fields = ['id', 'title', 'sn', 'workflow', 'workflow_', 'state', 'state_', 'act_state', 'create_time', 'update_time', 'participant_type', 'create_by'] + + @staticmethod + def setup_eager_loading(queryset): + queryset = queryset.select_related('workflow','state') + return queryset + +class TicketDetailSerializer(serializers.ModelSerializer): + workflow_ = WorkflowSimpleSerializer(source='workflow', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + ticket_data_ = serializers.SerializerMethodField() + class Meta: + model = Ticket + fields = '__all__' + + @staticmethod + def setup_eager_loading(queryset): + queryset = queryset.select_related('workflow','state') + return queryset + + def get_ticket_data_(self, obj): + ticket_data = obj.ticket_data + state_fields = obj.state.state_fields + all_fields = CustomField.objects.filter(workflow=obj.workflow).order_by('sort') + all_fields_l = CustomFieldSerializer(instance=all_fields, many=True).data + for i in all_fields_l: + key = i['field_key'] + i['field_state'] = state_fields.get(key, 1) + i['field_value'] = ticket_data.get(key, None) + i['field_display'] = i['field_value'] # 该字段是用于查看详情直接展示 + if i['field_value']: + if 'sys_user' in i['label']: + if isinstance(i['field_value'], list): + i['field_display'] = ','.join(list(User.objects.filter(id__in=i['field_value']).values_list('name', flat=True))) + else: + i['field_display'] = User.objects.get(id=i['field_value']).name + elif 'deptSelect' in i['label']: + if isinstance(i['field_value'], list): + i['field_display'] = ','.join(list(Organization.objects.filter(id__in=i['field_value']).values_list('name', flat=True))) + else: + i['field_display'] = Organization.objects.get(id=i['field_value']).name + elif i['field_type'] in ['radio', 'select']: + for m in i['field_choice']: + if m['id'] == i['field_value']: + i['field_display'] = m['name'] + elif i['field_type'] in ['checkbox', 'selects']: + d_list = [] + for m in i['field_choice']: + if m['id'] in i['field_value']: + d_list.append(m['name']) + i['field_display'] = ','.join(d_list) + return all_fields_l + + def filter_display(self, item, field_value): + if item['id'] == field_value: + return + +class TicketFlowSerializer(serializers.ModelSerializer): + participant_ = UserSimpleSerializer(source='participant', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + class Meta: + model = TicketFlow + fields = '__all__' + +class TicketFlowSimpleSerializer(serializers.ModelSerializer): + participant_ = UserSimpleSerializer(source='participant', read_only=True) + state_ = StateSimpleSerializer(source='state', read_only=True) + class Meta: + model = TicketFlow + exclude = ['ticket_data'] + + +class TicketHandleSerializer(serializers.Serializer): + transition = serializers.PrimaryKeyRelatedField(queryset=Transition.objects.all(), label="流转id") + ticket_data = serializers.JSONField(label="表单数据json") + suggestion = serializers.CharField(label="处理意见", required = False, allow_blank=True) + +class TicketRetreatSerializer(serializers.Serializer): + suggestion = serializers.CharField(label="撤回原因", required = False) + +class TicketCloseSerializer(serializers.Serializer): + suggestion = serializers.CharField(label="关闭原因", required = False) + +class TicketAddNodeSerializer(serializers.Serializer): + suggestion = serializers.CharField(label="加签说明", required = False) + toadd_user = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), label='发送给谁去加签') + +class TicketAddNodeEndSerializer(serializers.Serializer): + suggestion = serializers.CharField(label="加签意见", required = False) + +class TicketDestorySerializer(serializers.Serializer): + ids = serializers.ListField(child=serializers.PrimaryKeyRelatedField(queryset=Ticket.objects.all()), label='工单ID列表') \ No newline at end of file diff --git a/admin/server/apps/wf/services.py b/admin/server/apps/wf/services.py new file mode 100644 index 0000000..178318c --- /dev/null +++ b/admin/server/apps/wf/services.py @@ -0,0 +1,353 @@ +from apps.wf.serializers import CustomFieldSerializer +from apps.wf.serializers import TicketSerializer, TicketSimpleSerializer +from typing import Tuple +from apps.system.models import User +from apps.wf.models import CustomField, State, Ticket, TicketFlow, Transition, Workflow +from rest_framework.exceptions import APIException, PermissionDenied +from django.utils import timezone +from datetime import timedelta +import random +from .scripts import GetParticipants, HandleScripts +from utils.queryset import get_parent_queryset + +class WfService(object): + @staticmethod + def get_worlflow_states(workflow:Workflow): + """ + 获取工作流状态列表 + """ + return State.objects.filter(workflow=workflow, is_deleted=False).order_by('sort') + + @staticmethod + def get_workflow_transitions(workflow:Workflow): + """ + 获取工作流流转列表 + """ + return Transition.objects.filter(workflow=workflow, is_deleted=False) + + @staticmethod + def get_workflow_start_state(workflow:Workflow): + """ + 获取工作流初始状态 + """ + try: + wf_state_obj = State.objects.get(workflow=workflow, type=State.STATE_TYPE_START, is_deleted=False) + return wf_state_obj + except: + raise Exception('工作流状态配置错误') + + @staticmethod + def get_workflow_end_state(workflow:Workflow): + """ + 获取工作流结束状态 + """ + try: + wf_state_obj = State.objects.get(workflow=workflow, type=State.STATE_TYPE_END, is_deleted=False) + return wf_state_obj + except: + raise Exception('工作流状态配置错误') + + @staticmethod + def get_workflow_custom_fields(workflow:Workflow): + """ + 获取工单字段 + """ + return CustomField.objects.filter(is_deleted=False, workflow=workflow).order_by('sort') + + @staticmethod + def get_workflow_custom_fields_list(workflow:Workflow): + """ + 获取工单字段key List + """ + return list(CustomField.objects.filter(is_deleted=False, workflow=workflow).order_by('sort').values_list('field_key', flat=True)) + + @classmethod + def get_ticket_transitions(cls, ticket:Ticket): + """ + 获取工单当前状态下可用的流转条件 + """ + return cls.get_state_transitions(ticket.state) + + @classmethod + def get_state_transitions(cls, state:State): + """ + 获取状态可执行的操作 + """ + return Transition.objects.filter(is_deleted=False, source_state=state).all() + + @classmethod + def get_ticket_steps(cls, ticket:Ticket): + steps = cls.get_worlflow_states(ticket.workflow) + nsteps_list = [] + for i in steps: + if ticket.state == i or (not i.is_hidden): + nsteps_list.append(i) + return nsteps_list + + @classmethod + def get_ticket_transitions(cls, ticket:Ticket): + """ + 获取工单可执行的操作 + """ + return cls.get_state_transitions(ticket.state) + + @classmethod + def get_transition_by_args(cls, kwargs:dict): + """ + 查询并获取流转 + """ + kwargs['is_deleted'] = False + return Transition.objects.filter(**kwargs).all() + + @classmethod + def get_ticket_sn(cls, workflow:Workflow): + """ + 生成工单流水号 + """ + now = timezone.now() + today = str(now)[:10]+' 00:00:00' + next_day = str(now+timedelta(days=1))[:10]+' 00:00:00' + ticket_day_count_new = Ticket.objects.filter(create_time__gte=today, create_time__lte=next_day, workflow=workflow).count()+1 + return '%s_%04d%02d%02d%04d' % (workflow.sn_prefix, now.year, now.month, now.day, ticket_day_count_new) + + + + @classmethod + def get_next_state_by_transition_and_ticket_info(cls, ticket:Ticket, transition: Transition, new_ticket_data:dict={})->object: + """ + 获取下个节点状态 + """ + source_state = ticket.state + destination_state = transition.destination_state + ticket_all_value = cls.get_ticket_all_field_value(ticket) + ticket_all_value.update(**new_ticket_data) + for key, value in ticket_all_value.items(): + if isinstance(ticket_all_value[key], str): + ticket_all_value[key] = "'" + ticket_all_value[key] + "'" + if transition.condition_expression: + for i in transition.condition_expression: + expression = i['expression'].format(**ticket_all_value) + import datetime, time # 用于支持条件表达式中对时间的操作 + if eval(expression, {'__builtins__':None}, {'datetime':datetime, 'time':time}): + destination_state = State.objects.get(pk=i['target_state']) + return destination_state + return destination_state + + @classmethod + def get_ticket_state_participant_info(cls, state:State, ticket:Ticket, new_ticket_data:dict={}, handler:User=None): + """ + 获取工单目标状态实际的处理人, 处理人类型 + """ + if state.type == State.STATE_TYPE_START: + """ + 回到初始状态 + """ + return dict(destination_participant_type=State.PARTICIPANT_TYPE_PERSONAL, + destination_participant=ticket.create_by.id, + multi_all_person={}) + elif state.type == State.STATE_TYPE_END: + """ + 到达结束状态 + """ + return dict(destination_participant_type=0, + destination_participant=0, + multi_all_person={}) + multi_all_person_dict = {} + destination_participant_type, destination_participant = state.participant_type, state.participant + if destination_participant_type == State.PARTICIPANT_TYPE_FIELD: + destination_participant = new_ticket_data.get(destination_participant, 0) if destination_participant in new_ticket_data \ + else Ticket.ticket_data.get(destination_participant, 0) + + elif destination_participant_type == State.PARTICIPANT_TYPE_FORMCODE:#代码获取 + destination_participant = getattr(GetParticipants, destination_participant)( + state=state, ticket=ticket, new_ticket_data=new_ticket_data, handler=handler) + + elif destination_participant_type == State.PARTICIPANT_TYPE_DEPT:#部门 + destination_participant = list(User.objects.filter(dept__in=destination_participant).values_list('id', flat=True)) + + elif destination_participant_type == State.PARTICIPANT_TYPE_ROLE:#角色 + user_queryset = User.objects.filter(roles__in=destination_participant) + # 如果选择了角色, 需要走过滤策略 + if state.filter_policy == 1: + depts = get_parent_queryset(ticket.belong_dept) + user_queryset = user_queryset.filter(dept__in=depts) + elif state.filter_policy == 2: + depts = get_parent_queryset(ticket.create_by.dept) + user_queryset = user_queryset.filter(dept__in=depts) + elif state.filter_policy == 3: + depts = get_parent_queryset(handler.dept) + user_queryset = user_queryset.filter(dept__in=depts) + destination_participant = list(user_queryset.values_list('id', flat=True)) + if type(destination_participant) == list: + destination_participant_type = State.PARTICIPANT_TYPE_MULTI + destination_participant = list(set(destination_participant)) + if len(destination_participant) == 1: # 如果只有一个人 + destination_participant_type = State.PARTICIPANT_TYPE_PERSONAL + destination_participant = destination_participant[0] + else: + destination_participant_type = State.PARTICIPANT_TYPE_PERSONAL + if destination_participant_type == State.PARTICIPANT_TYPE_MULTI: + if state.distribute_type == State.STATE_DISTRIBUTE_TYPE_RANDOM: + destination_participant = random.choice(destination_participant) + elif state.distribute_type == State.STATE_DISTRIBUTE_TYPE_ALL: + for i in destination_participant: + multi_all_person_dict[i]={} + + return dict(destination_participant_type=destination_participant_type, + destination_participant=destination_participant, + multi_all_person=multi_all_person_dict) + + @classmethod + def ticket_handle_permission_check(cls, ticket:Ticket, user:User)-> dict: + transitions = cls.get_state_transitions(ticket.state) + if not transitions: + return dict(permission=True, msg="工单当前状态无需操作") + current_participant_count = 0 + participant_type = ticket.participant_type + participant = ticket.participant + state = ticket.state + if participant_type == State.PARTICIPANT_TYPE_PERSONAL: + if user.id != participant: + return dict(permission=False, msg="非当前处理人", need_accept=False) + elif participant_type in [State.PARTICIPANT_TYPE_MULTI, State.PARTICIPANT_TYPE_DEPT, State.PARTICIPANT_TYPE_ROLE]: + if user.id not in participant: + return dict(permission=False, msg="非当前处理人", need_accept=False) + current_participant_count = len(participant) + if current_participant_count == 1: + if [user.id] == participant or user.id == participant: + pass + else: + return dict(permission=False, msg="非当前处理人", need_accept=False) + elif current_participant_count >1 and state.distribute_type == State.STATE_DISTRIBUTE_TYPE_ACTIVE: + if user.id not in participant: + return dict(permission=False, msg="非当前处理人", need_accept=False) + return dict(permission=False, msg="需要先接单再处理", need_accept=True) + if ticket.in_add_node: + return dict(permission=False, msg="工单当前处于加签中,请加签完成后操作", need_accept=False) + return dict(permission=True, msg="", need_accept=False) + + @classmethod + def check_dict_has_all_same_value(cls, dict_obj: object)->tuple: + """ + check whether all key are equal in a dict + :param dict_obj: + :return: + """ + value_list = [] + for key, value in dict_obj.items(): + value_list.append(value) + value_0 = value_list[0] + for value in value_list: + if value_0 != value: + return False + return True + + @classmethod + def get_ticket_all_field_value(cls, ticket: Ticket)->dict: + """ + 工单所有字段的值 + get ticket's all field value + :param ticket: + :return: + """ + # 获取工单基础表中的字段中的字段信息 + field_info_dict = TicketSimpleSerializer(instance=ticket).data + # 获取自定义字段的值 + custom_fields_queryset = cls.get_workflow_custom_fields(ticket.workflow) + for i in custom_fields_queryset: + field_info_dict[i.field_key] = ticket.ticket_data.get(i.field_key, None) + return field_info_dict + + @classmethod + def handle_ticket(cls, ticket:Ticket, transition: Transition, new_ticket_data:dict={}, handler:User=None, + suggestion:str='', created:bool=False, by_timer:bool=False, by_task:bool=False, by_hook:bool=False): + + source_state = ticket.state + source_ticket_data = ticket.ticket_data + + # 校验处理权限 + if not handler or not created: # 没有处理人意味着系统触发不校验处理权限 + result = WfService.ticket_handle_permission_check(ticket, handler) + if result.get('permission') is False: + raise PermissionDenied(result.get('msg')) + + # 校验表单必填项目 + if transition.field_require_check or not created: + for key, value in ticket.state.state_fields.items(): + if int(value) == State.STATE_FIELD_REQUIRED: + if key not in new_ticket_data or not new_ticket_data[key]: + raise APIException('字段{}必填'.format(key)) + + destination_state = cls.get_next_state_by_transition_and_ticket_info(ticket, transition, new_ticket_data) + multi_all_person = ticket.multi_all_person + if multi_all_person: + multi_all_person[handler.id] =dict(transition=transition.id) + # 判断所有人处理结果是否一致 + if WfService.check_dict_has_all_same_value(multi_all_person): + participant_info = WfService.get_ticket_state_participant_info(destination_state, ticket, new_ticket_data) + destination_participant_type = participant_info.get('destination_participant_type', 0) + destination_participant = participant_info.get('destination_participant', 0) + multi_all_person = {} + else: + # 处理人没有没有全部处理完成或者处理动作不一致 + destination_participant_type = ticket.participant_type + destination_state = ticket.state # 保持原状态 + destination_participant = [] + for key, value in multi_all_person.items(): + if not value: + destination_participant.append(key) + else: + # 当前处理人类型非全部处理 + participant_info = WfService.get_ticket_state_participant_info(destination_state, ticket, new_ticket_data) + destination_participant_type = participant_info.get('destination_participant_type', 0) + destination_participant = participant_info.get('destination_participant', 0) + multi_all_person = participant_info.get('multi_all_person', {}) + + # 更新工单信息:基础字段及自定义字段, add_relation字段 需要下个处理人是部门、角色等的情况 + ticket.state = destination_state + ticket.participant_type = destination_participant_type + ticket.participant = destination_participant + ticket.multi_all_person = multi_all_person + if destination_state.type == State.STATE_TYPE_END: + ticket.act_state = Ticket.TICKET_ACT_STATE_FINISH + elif destination_state.type == State.STATE_TYPE_START: + ticket.act_state = Ticket.TICKET_ACT_STATE_DRAFT + else: + ticket.act_state = Ticket.TICKET_ACT_STATE_ONGOING + + if transition.attribute_type == Transition.TRANSITION_ATTRIBUTE_TYPE_REFUSE: + ticket.act_state = Ticket.TICKET_ACT_STATE_BACK + + # 只更新必填和可选的字段 + if not created: + for key, value in source_state.state_fields.items(): + if value in (State.STATE_FIELD_REQUIRED, State.STATE_FIELD_OPTIONAL): + if key in new_ticket_data: + source_ticket_data[key] = new_ticket_data[key] + ticket.ticket_data = source_ticket_data + ticket.save() + + # 更新工单流转记录 + if not by_task: + TicketFlow.objects.create(ticket=ticket, state=source_state, ticket_data=WfService.get_ticket_all_field_value(ticket), + suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, + participant=handler, transition=transition) + + if created: + if source_state.participant_cc: + TicketFlow.objects.create(ticket=ticket, state=source_state, + participant_type=0, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CC, + participant=None, participant_cc=source_state.participant_cc) + + # 目标状态需要抄送 + if destination_state.participant_cc: + TicketFlow.objects.create(ticket=ticket, state=destination_state, + participant_type=0, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CC, + participant=None, participant_cc=destination_state.participant_cc) + + # 如果目标状态是脚本则执行 + if destination_state.participant_type == State.PARTICIPANT_TYPE_ROBOT: + getattr(HandleScripts, destination_state.participant)(ticket) + + return ticket + diff --git a/admin/server/apps/wf/tests.py b/admin/server/apps/wf/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/admin/server/apps/wf/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/admin/server/apps/wf/urls.py b/admin/server/apps/wf/urls.py new file mode 100644 index 0000000..5c9ae0d --- /dev/null +++ b/admin/server/apps/wf/urls.py @@ -0,0 +1,18 @@ +from django.db.models import base +from rest_framework import urlpatterns +from apps.wf.views import CustomFieldViewSet, FromCodeListView, StateViewSet, TicketFlowViewSet, TicketViewSet, TransitionViewSet, WorkflowViewSet +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +router.register('workflow', WorkflowViewSet, basename='wf') +router.register('state', StateViewSet, basename='wf_state') +router.register('transition', TransitionViewSet, basename='wf_transitions') +router.register('customfield', CustomFieldViewSet, basename='wf_customfield') +router.register('ticket', TicketViewSet, basename='wf_ticket') +router.register('ticketflow', TicketFlowViewSet, basename='wf_ticketflow') +urlpatterns = [ + path('participant_from_code', FromCodeListView.as_view()), + path('', include(router.urls)), +] + diff --git a/admin/server/apps/wf/views.py b/admin/server/apps/wf/views.py new file mode 100644 index 0000000..2a7e073 --- /dev/null +++ b/admin/server/apps/wf/views.py @@ -0,0 +1,373 @@ +from django.utils import timezone +from django.db import transaction +from django.db.models import query +from rest_framework.utils import serializer_helpers +from rest_framework.views import APIView +from apps.system.models import User +from apps.wf.filters import TicketFilterSet +from django.core.exceptions import AppRegistryNotReady +from rest_framework.response import Response +from rest_framework import serializers +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin +from apps.wf.serializers import CustomFieldCreateUpdateSerializer, CustomFieldSerializer, StateSerializer, TicketAddNodeEndSerializer, TicketAddNodeSerializer, TicketCloseSerializer, TicketCreateSerializer, TicketDestorySerializer, TicketFlowSerializer, TicketFlowSimpleSerializer, TicketHandleSerializer, TicketRetreatSerializer, TicketSerializer, TransitionSerializer, WorkflowSerializer, TicketListSerializer, TicketDetailSerializer +from django.shortcuts import get_object_or_404, render +from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework.decorators import action, api_view +from apps.wf.models import CustomField, Ticket, Workflow, State, Transition, TicketFlow +from apps.system.mixins import CreateUpdateCustomMixin, CreateUpdateModelAMixin, OptimizationMixin +from apps.wf.services import WfService +from rest_framework.exceptions import APIException, PermissionDenied +from rest_framework import status +from django.db.models import Count +from .scripts import GetParticipants, HandleScripts + + +# Create your views here. +class FromCodeListView(APIView): + def get(self, request, format=None): + """ + 获取处理人代码列表 + """ + return Response(GetParticipants.all_funcs) + +class WorkflowViewSet(CreateUpdateModelAMixin, ModelViewSet): + perms_map = {'get': '*', 'post': 'workflow_create', + 'put': 'workflow_update', 'delete': 'workflow_delete'} + queryset = Workflow.objects.all() + serializer_class = WorkflowSerializer + search_fields = ['name', 'description'] + filterset_fields = [] + ordering_fields = ['create_time', 'name', 'key'] + ordering = ['create_time'] + + @action(methods=['get'], detail=True, perms_map={'get':'workflow_update'}, pagination_class=None, serializer_class=StateSerializer) + def states(self, request, pk=None): + """ + 工作流下的状态节点 + """ + wf = self.get_object() + serializer = self.serializer_class(instance=WfService.get_worlflow_states(wf), many=True) + return Response(serializer.data) + + @action(methods=['get'], detail=True, perms_map={'get':'workflow_update'}, pagination_class=None, serializer_class=TransitionSerializer) + def transitions(self, request, pk=None): + """ + 工作流下的流转规则 + """ + wf = self.get_object() + serializer = self.serializer_class(instance=WfService.get_workflow_transitions(wf), many=True) + return Response(serializer.data) + + @action(methods=['get'], detail=True, perms_map={'get':'workflow_update'}, pagination_class=None, serializer_class=CustomFieldSerializer) + def customfields(self, request, pk=None): + """ + 工作流下的自定义字段 + """ + wf = self.get_object() + serializer = self.serializer_class(instance=CustomField.objects.filter(workflow=wf, is_deleted=False).order_by('sort'), many=True) + return Response(serializer.data) + + @action(methods=['get'], detail=True, perms_map={'get':'workflow_init'}) + def init(self, request, pk=None): + """ + 新建工单初始化 + """ + ret={} + wf = self.get_object() + start_state = WfService.get_workflow_start_state(wf) + transitions = WfService.get_state_transitions(start_state) + ret['workflow'] = pk + ret['transitions'] = TransitionSerializer(instance=transitions, many=True).data + field_list = CustomFieldSerializer(instance=WfService.get_workflow_custom_fields(wf), many=True).data + for i in field_list: + if i['field_key'] in start_state.state_fields: + i['field_attribute'] = start_state.state_fields[i['field_key']] + else: + i['field_attribute'] = State.STATE_FIELD_READONLY + ret['field_list'] = field_list + return Response(ret) + +class StateViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, GenericViewSet): + perms_map = {'get':'*', 'post':'workflow_update', + 'put':'workflow_update', 'delete':'workflow_update'} + queryset = State.objects.all() + serializer_class = StateSerializer + search_fields = ['name'] + filterset_fields = ['workflow'] + ordering = ['sort'] + +class TransitionViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, GenericViewSet): + perms_map = {'get':'*', 'post':'workflow_update', + 'put':'workflow_update', 'delete':'workflow_update'} + queryset = Transition.objects.all() + serializer_class = TransitionSerializer + search_fields = ['name'] + filterset_fields = ['workflow'] + ordering = ['id'] + +class CustomFieldViewSet(CreateModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, GenericViewSet): + perms_map = {'get':'*', 'post':'workflow_update', + 'put':'workflow_update', 'delete':'workflow_update'} + queryset = CustomField.objects.all() + serializer_class = CustomFieldSerializer + search_fields = ['field_name'] + filterset_fields = ['workflow', 'field_type'] + ordering = ['sort'] + + def get_serializer_class(self): + if self.action in ['create', 'update']: + return CustomFieldCreateUpdateSerializer + return super().get_serializer_class() + +class TicketViewSet(OptimizationMixin, CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet): + perms_map = {'get':'*', 'post':'ticket_create'} + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + search_fields = ['title'] + filterset_class = TicketFilterSet + ordering = ['-create_time'] + + def get_serializer_class(self): + if self.action == 'create': + return TicketCreateSerializer + elif self.action == 'handle': + return TicketHandleSerializer + elif self.action == 'retreat': + return TicketRetreatSerializer + elif self.action == 'list': + return TicketListSerializer + elif self.action == 'retrieve': + return TicketDetailSerializer + return super().get_serializer_class() + + def filter_queryset(self, queryset): + if not self.detail and not self.request.query_params.get('category', None): + raise APIException('请指定查询分类') + return super().filter_queryset(queryset) + + @transaction.atomic + def create(self, request, *args, **kwargs): + """ + 新建工单 + """ + rdata = request.data + serializer = self.get_serializer(data=rdata) + serializer.is_valid(raise_exception=True) + vdata = serializer.validated_data #校验之后的数据 + start_state = WfService.get_workflow_start_state(vdata['workflow']) + transition = vdata.pop('transition') + ticket_data = vdata['ticket_data'] + + save_ticket_data = {} + # 校验必填项 + if transition.field_require_check: + for key, value in start_state.state_fields.items(): + if int(value) == State.STATE_FIELD_REQUIRED: + if key not in ticket_data and not ticket_data[key]: + raise APIException('字段{}必填'.format(key)) + save_ticket_data[key] = ticket_data[key] + elif int(value) == State.STATE_FIELD_OPTIONAL: + save_ticket_data[key] = ticket_data[key] + + ticket = serializer.save(state=start_state, + create_by=request.user, + create_time=timezone.now(), + act_state=Ticket.TICKET_ACT_STATE_DRAFT, + belong_dept=request.user.dept, + ticket_data=save_ticket_data) # 先创建出来 + # 更新title和sn + title = vdata.get('title', '') + title_template = ticket.workflow.title_template + if title_template: + all_ticket_data = {**rdata, **ticket_data} + title = title_template.format(**all_ticket_data) + sn = WfService.get_ticket_sn(ticket.workflow) # 流水号 + ticket.sn = sn + ticket.title = title + ticket.save() + ticket = WfService.handle_ticket(ticket=ticket, transition=transition, new_ticket_data=ticket_data, + handler=request.user, created=True) + return Response(TicketSerializer(instance=ticket).data) + + @action(methods=['get'], detail=False, perms_map={'get':'*'}) + def duty_agg(self, request, pk=None): + """ + 工单待办聚合 + """ + ret = {} + queryset = Ticket.objects.filter(participant__contains=request.user.id, is_deleted=False)\ + .exclude(act_state__in=[Ticket.TICKET_ACT_STATE_FINISH, Ticket.TICKET_ACT_STATE_CLOSED]) + ret['total_count'] = queryset.count() + ret['details'] = list(queryset.values('workflow', 'workflow__name').annotate(count = Count('workflow'))) + return Response(ret) + + @action(methods=['post'], detail=True, perms_map={'post':'*'}) + @transaction.atomic + def handle(self, request, pk=None): + """ + 处理工单 + """ + ticket = self.get_object() + serializer = TicketHandleSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + vdata = serializer.validated_data + new_ticket_data = ticket.ticket_data + new_ticket_data.update(**vdata['ticket_data']) + + ticket = WfService.handle_ticket(ticket=ticket, transition=vdata['transition'], + new_ticket_data=new_ticket_data, handler=request.user, suggestion=vdata['suggestion']) + return Response(TicketSerializer(instance=ticket).data) + + + @action(methods=['get'], detail=True, perms_map={'get':'*'}) + def flowsteps(self, request, pk=None): + """ + 工单流转step, 用于显示当前状态的step图(线性结构) + """ + ticket = self.get_object() + steps = WfService.get_ticket_steps(ticket) + return Response(StateSerializer(instance=steps, many=True).data) + + @action(methods=['get'], detail=True, perms_map={'get':'*'}) + def flowlogs(self, request, pk=None): + """ + 工单流转记录 + """ + ticket = self.get_object() + flowlogs = TicketFlow.objects.filter(ticket=ticket).order_by('-create_time') + serializer = TicketFlowSerializer(instance=flowlogs, many=True) + return Response(serializer.data) + + @action(methods=['get'], detail=True, perms_map={'get':'*'}) + def transitions(self, request, pk=None): + """ + 获取工单可执行的操作 + """ + ticket = self.get_object() + transitions = WfService.get_ticket_transitions(ticket) + return Response(TransitionSerializer(instance=transitions, many=True).data) + + @action(methods=['post'], detail=True, perms_map={'post':'*'}) + def accpet(self, request, pk=None): + """ + 接单,当工单当前处理人实际为多个人时(角色、部门、多人都有可能, 注意角色和部门有可能实际只有一人) + """ + ticket = self.get_object() + result = WfService.ticket_handle_permission_check(ticket, request.user) + if result.get('need_accept', False): + ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL + ticket.participant = request.user.id + ticket.save() + # 接单日志 + # 更新工单流转记录 + TicketFlow.objects.create(ticket=ticket, state=ticket.state, ticket_data=WfService.get_ticket_all_field_value(ticket), + suggestion='', participant_type=State.PARTICIPANT_TYPE_PERSONAL, intervene_type=Transition.TRANSITION_ATTRIBUTE_TYPE_ACCEPT, + participant=request.user, transition=None) + return Response() + else: + raise APIException('无需接单') + + @action(methods=['post'], detail=True, perms_map={'post':'*'}) + def retreat(self, request, pk=None): + """ + 撤回工单,允许创建人在指定状态撤回工单至初始状态,状态设置中开启允许撤回 + """ + ticket = self.get_object() + if ticket.create_by != request.user: + raise APIException('非创建人不可撤回') + if not ticket.state.enable_retreat: + raise APIException('该状态不可撤回') + start_state = WfService.get_workflow_start_state(ticket.workflow) + ticket.state = start_state + ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL + ticket.participant = request.user.id + ticket.act_state = Ticket.TICKET_ACT_STATE_RETREAT + ticket.save() + # 更新流转记录 + suggestion = request.data.get('suggestion', '') # 撤回原因 + TicketFlow.objects.create(ticket=ticket, state=ticket.state, ticket_data=WfService.get_ticket_all_field_value(ticket), + suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_RETREAT, + participant=request.user, transition=None) + return Response() + + @action(methods=['post'], detail=True, perms_map={'post':'*'}, serializer_class=TicketAddNodeSerializer) + def add_node(self, request, pk=None): + """ + 加签 + """ + ticket = self.get_object() + data = request.data + add_user = User.objects.get(pk=data['toadd_user']) + ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL + ticket.participant = add_user.id + ticket.in_add_node = True + ticket.add_node_man = request.user + ticket.save() + # 更新流转记录 + suggestion = request.data.get('suggestion', '') # 加签说明 + TicketFlow.objects.create(ticket=ticket, state=ticket.state, ticket_data=WfService.get_ticket_all_field_value(ticket), + suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE, + participant=request.user, transition=None) + return Response() + + @action(methods=['post'], detail=True, perms_map={'post':'*'}, serializer_class=TicketAddNodeEndSerializer) + def add_node_end(self, request, pk=None): + """ + 加签完成 + """ + ticket = self.get_object() + ticket.participant_type = State.PARTICIPANT_TYPE_PERSONAL + ticket.in_add_node = False + ticket.participant = ticket.add_node_man.id + ticket.add_node_man = None + ticket.save() + # 更新流转记录 + suggestion = request.data.get('suggestion', '') # 加签意见 + TicketFlow.objects.create(ticket=ticket, state=ticket.state, ticket_data=WfService.get_ticket_all_field_value(ticket), + suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_ADD_NODE_END, + participant=request.user, transition=None) + return Response() + + + @action(methods=['post'], detail=True, perms_map={'post':'*'}, serializer_class=TicketCloseSerializer) + def close(self, request, pk=None): + """ + 关闭工单(创建人在初始状态) + """ + ticket = self.get_object() + if ticket.state.type == State.STATE_TYPE_START and ticket.create_by==request.user: + end_state = WfService.get_workflow_end_state(ticket.workflow) + ticket.state = end_state + ticket.participant_type = 0 + ticket.participant = 0 + ticket.act_state = Ticket.TICKET_ACT_STATE_CLOSED + ticket.save() + # 更新流转记录 + suggestion = request.data.get('suggestion', '') # 关闭原因 + TicketFlow.objects.create(ticket=ticket, state=ticket.state, ticket_data=WfService.get_ticket_all_field_value(ticket), + suggestion=suggestion, participant_type=State.PARTICIPANT_TYPE_PERSONAL, intervene_type=Transition.TRANSITION_INTERVENE_TYPE_CLOSE, + participant=request.user, transition=None) + return Response() + else: + return Response('工单不可关闭', status=status.HTTP_400_BAD_REQUEST) + + @action(methods=['post'], detail=False, perms_map={'post':'ticket_deletes'}, serializer_class=TicketDestorySerializer) + def destory(self, request, pk=None): + """ + 批量物理删除 + """ + Ticket.objects.filter(id__in=request.data.get('ids', [])).delete(soft=False) + return Response() + + + +class TicketFlowViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + """ + 工单日志 + """ + perms_map = {'get':'*'} + queryset = TicketFlow.objects.all() + serializer_class = TicketFlowSerializer + search_fields = ['suggestion'] + filterset_fields = ['ticket'] + ordering = ['-create_time'] \ No newline at end of file diff --git a/admin/server/db.json b/admin/server/db.json new file mode 100644 index 0000000..e75d03d --- /dev/null +++ b/admin/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/admin/server/log/.gitignore b/admin/server/log/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/admin/server/log/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/admin/server/manage.py b/admin/server/manage.py new file mode 100644 index 0000000..1c81878 --- /dev/null +++ b/admin/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') + 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/admin/server/media/default/avatar.png b/admin/server/media/default/avatar.png new file mode 100644 index 0000000..98d206e Binary files /dev/null and b/admin/server/media/default/avatar.png differ diff --git a/admin/server/requirements.txt b/admin/server/requirements.txt new file mode 100644 index 0000000..2a791a6 --- /dev/null +++ b/admin/server/requirements.txt @@ -0,0 +1,12 @@ +celery==5.2.7 +Django==3.2.23 +django-celery-beat==2.4.0 +django-celery-results==2.4.0 +django-cors-headers==3.13.0 +django-filter==21.1 +django-simple-history==3.0.0 +djangorestframework==3.12.4 +djangorestframework-simplejwt==4.8.0 +drf-yasg==1.20.0 +psutil==5.9.0 +redis==4.5.5 diff --git a/admin/server/server/__init__.py b/admin/server/server/__init__.py new file mode 100644 index 0000000..1e3599b --- /dev/null +++ b/admin/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/admin/server/server/asgi.py b/admin/server/server/asgi.py new file mode 100644 index 0000000..2526a47 --- /dev/null +++ b/admin/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/admin/server/server/celery.py b/admin/server/server/celery.py new file mode 100644 index 0000000..dbe6eb8 --- /dev/null +++ b/admin/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') + +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/admin/server/server/conf_e.py b/admin/server/server/conf_e.py new file mode 100644 index 0000000..9608165 --- /dev/null +++ b/admin/server/server/conf_e.py @@ -0,0 +1,18 @@ +DEBUG = True +DATABASES = { + # 'default': { + # 'ENGINE': 'django.db.backends.postgresql', + # 'NAME': 'demo', + # 'USER': 'postgres', + # 'PASSWORD': '123456', + # 'HOST': 'localhost', + # 'PORT': '5432', + # } + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'db.sqlite3' + } +} + +WECHAT_APP_ID = 'wx2d9b9759137ef46b' +WECHAT_APP_SECRET = '3ee93171282de2ad1eebdb656ee48d4e' diff --git a/admin/server/server/settings.py b/admin/server/server/settings.py new file mode 100644 index 0000000..c325ef7 --- /dev/null +++ b/admin/server/server/settings.py @@ -0,0 +1,314 @@ +""" +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 +import logging +from . import conf + +# 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! +DEBUG = conf.DEBUG + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + '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', + 'apps.crm' +] + +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': [os.path.join(BASE_DIR, 'dist')], + '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 +DATABASES = conf.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, 'dist/static') +if DEBUG: + STATIC_ROOT = None + STATICFILES_DIRS = ( + os.path.join(BASE_DIR, 'dist/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': [ + 'apps.crm.authentication.MiniProgramAuthentication', + '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', +} +# simplejwt配置 +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), +} + +# 跨域配置/可用nginx处理,无需引入corsheaders +CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_CREDENTIALS = False + +SILENCED_SYSTEM_CHECKS = ['fields.E180'] + +# Auth配置 +AUTH_USER_MODEL = 'system.User' +AUTHENTICATION_BACKENDS = ( + 'apps.system.authentication.CustomBackend', +) + +# 缓存配置,使用redis +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/1", + } +} + +if DEBUG: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "server-cache", + } + } + +# celery配置,celery正常运行必须安装redis +CELERY_BROKER_URL = "redis://localhost:6379/0" # 任务存储 +CELERYD_MAX_TASKS_PER_CHILD = 100 # 每个worker最多执行300个任务就会被销毁,可防止内存泄露 +CELERY_TIMEZONE = 'Asia/Shanghai' # 设置时区 +CELERY_ENABLE_UTC = True # 启动时区设置 +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' + +# swagger配置 +SWAGGER_SETTINGS = { + 'LOGIN_URL':'/django/admin/login/', + 'LOGOUT_URL':'/django/admin/logout/' +} + +# 日志配置 +# 创建日志的路径 +LOG_PATH = os.path.join(BASE_DIR, 'log') +# 如果地址不存在,则自动创建log文件夹 +if not os.path.exists(LOG_PATH): + os.mkdir(LOG_PATH) + +class TimedSizeRotatingHandler(logging.handlers.TimedRotatingFileHandler): + def __init__(self, filename, when='midnight', interval=1, backupCount=0, + maxBytes=0, encoding=None, delay=False, utc=False, atTime=None): + super().__init__(filename, when, interval, backupCount, encoding, delay, utc, atTime) + self.maxBytes = maxBytes + + def shouldRollover(self, record): + if self.maxBytes > 0 and os.path.exists(self.baseFilename): + if os.stat(self.baseFilename).st_size >= self.maxBytes: + return True + return super().shouldRollover(record) + +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': 'server.settings.TimedSizeRotatingHandler', + 'filename': os.path.join(LOG_PATH, 'all.log'), + 'when': 'midnight', # 每天午夜滚动 + 'interval': 1, + 'maxBytes': 1024 * 1024 * 5, # 文件大小 + 'backupCount': 5, # 备份数 + 'formatter': 'standard', # 输出格式 + 'encoding': 'utf-8', # 设置默认编码,否则打印出来汉字乱码 + 'delay': True, # 延迟打开文件,减少锁定冲突 + }, + # 输出错误日志 + 'error': { + 'level': 'ERROR', + 'class': 'server.settings.TimedSizeRotatingHandler', + 'filename': os.path.join(LOG_PATH, 'error.log'), + 'when': 'midnight', # 每天午夜滚动 + 'interval': 1, + 'maxBytes': 1024 * 1024 * 5, # 文件大小 + 'backupCount': 5, # 备份数 + 'formatter': 'standard', # 输出格式 + 'encoding': 'utf-8', # 设置默认编码 + 'delay': True, + }, + # 控制台输出 + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'filters': ['require_debug_true'], + 'formatter': 'standard' + }, + # 输出info日志 + 'info': { + 'level': 'INFO', + 'class': 'server.settings.TimedSizeRotatingHandler', + 'filename': os.path.join(LOG_PATH, 'info.log'), + 'when': 'midnight', # 每天午夜滚动 + 'interval': 1, + 'maxBytes': 1024 * 1024 * 5, + 'backupCount': 5, + 'formatter': 'standard', + 'encoding': 'utf-8', # 设置默认编码 + 'delay': True, + }, + }, + # 配置用哪几种 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/admin/server/server/urls.py b/admin/server/server/urls.py new file mode 100644 index 0000000..b3d1d23 --- /dev/null +++ b/admin/server/server/urls.py @@ -0,0 +1,66 @@ +"""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="Django-Vue-Admin API", + default_version='v1', + contact=openapi.Contact(email="caoqianming@foxmail.com"), + license=openapi.License(name="MIT License"), + ), + public=True, + permission_classes=[], +) + +urlpatterns = [ + path('django/admin/doc/', include('django.contrib.admindocs.urls')), + path('django/admin/', admin.site.urls), + + # 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('api/wf/', include('apps.wf.urls')), + path('api/', include('apps.crm.urls')), + + # api文档 + 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'), + + # 前端页面入口 + 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/admin/server/server/wsgi.py b/admin/server/server/wsgi.py new file mode 100644 index 0000000..c65f7e2 --- /dev/null +++ b/admin/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') + +application = get_wsgi_application() diff --git a/admin/server/start.sh b/admin/server/start.sh new file mode 100644 index 0000000..b619690 --- /dev/null +++ b/admin/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/admin/server/utils/__init__.py b/admin/server/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/server/utils/model.py b/admin/server/utils/model.py new file mode 100644 index 0000000..05d04ab --- /dev/null +++ b/admin/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/admin/server/utils/pagination.py b/admin/server/utils/pagination.py new file mode 100644 index 0000000..95cf9da --- /dev/null +++ b/admin/server/utils/pagination.py @@ -0,0 +1,23 @@ +from rest_framework.pagination import PageNumberPagination +from rest_framework.exceptions import ParseError + +class MyPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + + def paginate_queryset(self, queryset, request, view=None): + if request.query_params.get('pageoff', None) or request.query_params.get('page', None) == '0': + if queryset.count() < 800: + return None + raise ParseError('单次请求数据量大,请分页获取') + return super().paginate_queryset(queryset, request, view=view) + +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/admin/server/utils/queryset.py b/admin/server/utils/queryset.py new file mode 100644 index 0000000..73a6bb1 --- /dev/null +++ b/admin/server/utils/queryset.py @@ -0,0 +1,70 @@ +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 + +def get_parent_queryset(obj, hasSelf=True): + cls = type(obj) + ids = [] + if hasSelf: + ids.append(obj.id) + while obj.parent: + obj = obj.parent + ids.append(obj.id) + return cls.objects.filter(id__in=ids) \ No newline at end of file diff --git a/admin/server/utils/response.py b/admin/server/utils/response.py new file mode 100644 index 0000000..f4379f4 --- /dev/null +++ b/admin/server/utils/response.py @@ -0,0 +1,63 @@ +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里是详细异常信息 + prefix = "" + if isinstance(data, dict): + prefix = list(data.keys())[0] + data = data[prefix] + if isinstance(data, list): + data = data[0] + response_body.msg = prefix + ":" + str(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/admin/server/utils/serializer.py b/admin/server/utils/serializer.py new file mode 100644 index 0000000..743bbfc --- /dev/null +++ b/admin/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/admin/server/utils/test.py b/admin/server/utils/test.py new file mode 100644 index 0000000..fbc1537 --- /dev/null +++ b/admin/server/utils/test.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + + + diff --git a/admin/server/utils/view.py b/admin/server/utils/view.py new file mode 100644 index 0000000..23707ff --- /dev/null +++ b/admin/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/admin/server/utils/workflow.py b/admin/server/utils/workflow.py new file mode 100644 index 0000000..8b5c0b9 --- /dev/null +++ b/admin/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/admin/specification.md b/admin/specification.md new file mode 100644 index 0000000..d4acd17 --- /dev/null +++ b/admin/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文件夹下 + + + diff --git a/create_repo.py b/create_repo.py new file mode 100644 index 0000000..ae7dd4e --- /dev/null +++ b/create_repo.py @@ -0,0 +1,140 @@ +import requests +import json +import subprocess +import os + +# Configuration +API_URL = "https://git.aitosuv.com/api/v1/user/repos" +AUTH = ('admin', 'lsy123123') +REPO_DATA = { + "name": "geminiWX", + "description": "微信小程序项目", + "private": False, + "auto_init": False +} + +def run_command(command): + """Run a shell command and return the output.""" + print(f"Running: {command}") + try: + result = subprocess.run( + command, + check=True, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + if result.stdout: + print(result.stdout.strip()) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Error running command: {command}") + print(e.stderr) + return None + +def create_gitignore(): + """Create .gitignore if it doesn't exist.""" + if not os.path.exists(".gitignore"): + content = """ +.venv/ +__pycache__/ +*.pyc +.trae/ +.vscode/ +.idea/ +*.log +.env +""" + with open(".gitignore", "w") as f: + f.write(content.strip()) + print("Created .gitignore") + else: + print(".gitignore already exists") + +def main(): + clone_url = None + + # 1. Create Repository via API + try: + response = requests.post(API_URL, auth=AUTH, json=REPO_DATA) + if response.status_code == 201: + print("Repository created successfully") + clone_url = response.json()['clone_url'] + elif response.status_code == 422 or response.status_code == 409: # Already exists + print("Repository already exists") + # Fetch existing repo details + user = AUTH[0] + repo_name = REPO_DATA["name"] + get_url = f"https://git.aitosuv.com/api/v1/repos/{user}/{repo_name}" + resp_get = requests.get(get_url, auth=AUTH) + if resp_get.status_code == 200: + clone_url = resp_get.json()['clone_url'] + else: + print(f"Could not fetch existing repository details. Status: {resp_get.status_code}") + else: + print(f"Failed to create repository: {response.status_code}") + print(response.text) + return + except Exception as e: + print(f"Error: {e}") + return + + if not clone_url: + print("Could not determine clone URL. Exiting.") + return + + # Embed credentials into the URL for automatic authentication + # Assuming clone_url format: https://git.aitosuv.com/admin/geminiWX.git + # We want: https://admin:lsy123123@git.aitosuv.com/admin/geminiWX.git + if "://" in clone_url: + protocol, rest = clone_url.split("://", 1) + auth_url = f"{protocol}://{AUTH[0]}:{AUTH[1]}@{rest}" + else: + auth_url = clone_url # Fallback if format is unexpected + + print(f"Target Remote URL: {clone_url}") + + # 2. Local Git Operations + if not os.path.exists(".git"): + print("Initializing git repository...") + run_command("git init") + + # Configure git user for this repository + print("Configuring git user...") + run_command(f'git config user.email "{AUTH[0]}@aitosuv.com"') + run_command(f'git config user.name "{AUTH[0]}"') + + create_gitignore() + + print("Adding files...") + run_command("git add .") + + print("Committing changes...") + run_command('git commit -m "Initial commit"') + + # Check and configure remote + remotes = run_command("git remote -v") + if remotes and "origin" in remotes: + print("Updating remote 'origin'...") + run_command(f"git remote set-url origin {auth_url}") + else: + print("Adding remote 'origin'...") + run_command(f"git remote add origin {auth_url}") + + # Push to remote + print("Pushing to remote...") + # Try pushing to master first, then main if that fails (or vice versa depending on default branch) + # Usually 'master' is default for older git, 'main' for newer. + # We can try checking current branch name. + current_branch = run_command("git rev-parse --abbrev-ref HEAD") + if current_branch: + if run_command(f"git push -u origin {current_branch}") is None: + print("Push failed.") + else: + # Fallback if we couldn't get branch name + if run_command("git push -u origin master") is None: + run_command("git push -u origin main") + +if __name__ == "__main__": + main() diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..b35d474 Binary files /dev/null and b/db.sqlite3 differ diff --git a/login-logo.png b/login-logo.png new file mode 100644 index 0000000..8dd7b2e Binary files /dev/null and b/login-logo.png differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..8e0ca42 Binary files /dev/null and b/logo.png differ diff --git a/setup_firewall.bat b/setup_firewall.bat new file mode 100644 index 0000000..944737f --- /dev/null +++ b/setup_firewall.bat @@ -0,0 +1,52 @@ +@echo off +:: Check for permissions +>nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system" + +:: If error flag set, we do not have admin. +if '%errorlevel%' NEQ '0' ( + echo Requesting administrative privileges... + goto UACPrompt +) else ( goto gotAdmin ) + +:UACPrompt + echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\getadmin.vbs" + echo UAC.ShellExecute "%~s0", "", "", "runas", 1 >> "%temp%\getadmin.vbs" + + "%temp%\getadmin.vbs" + exit /B + +:gotAdmin + if exist "%temp%\getadmin.vbs" ( del "%temp%\getadmin.vbs" ) + pushd "%CD%" + CD /D "%~dp0" + +echo ======================================================== +echo Adding Firewall Rule for Django Dev Server & WeChat Debug (Ports 8000-9000) +echo ======================================================== + +netsh advfirewall firewall show rule name="DjangoDevServer" >nul +if %errorlevel% equ 0 ( + echo Rule "DjangoDevServer" already exists. Deleting old rule... + netsh advfirewall firewall delete rule name="DjangoDevServer" +) + +:: Add rule for TCP ports 8000-9000, allowing connection from any profile (Domain, Private, Public) +netsh advfirewall firewall add rule name="DjangoDevServer" dir=in action=allow protocol=TCP localport=8000-9000 profile=any +if %errorlevel% equ 0 ( + echo [SUCCESS] Firewall rule added successfully. Ports 8000-9000 are now open on ALL network profiles. +) else ( + echo [ERROR] Failed to add firewall rule. +) + +echo. +echo ======================================================== +echo Network Configuration Info: +echo ======================================================== +ipconfig | findstr "IPv4" +echo. +echo IMPORTANT: +echo 1. Ensure your phone is connected to the SAME Wi-Fi as this computer. +echo 2. Do NOT use mobile data (4G/5G). +echo 3. If you have 3rd party antivirus (360, McAfee, etc.), you may need to manually allow port 8000. +echo. +pause diff --git a/wechat-mini-program/app.js b/wechat-mini-program/app.js new file mode 100644 index 0000000..479d82c --- /dev/null +++ b/wechat-mini-program/app.js @@ -0,0 +1,84 @@ +const env = require('./config/env') + +App({ + onLaunch() { + console.log('App Launch') + this.login(); + }, + globalData: { + userInfo: null, + baseUrl: env.baseUrl, + token: null + }, + login() { + wx.login({ + success: res => { + if (res.code) { + // 发送 res.code 到后台换取 openId, sessionKey, unionId + wx.request({ + url: `${this.globalData.baseUrl}/auth/login/`, + method: 'POST', + data: { + code: res.code + }, + success: (response) => { + // FitJSONRenderer wraps response in { code, data, msg } + const resBody = response.data; + if (response.statusCode === 200 && resBody.code >= 200 && resBody.code < 300) { + // Handle wrapped data + const payload = resBody.data || resBody; + const { token, user, is_new_user } = payload; + + if (!token || !user) { + console.error('Login response missing token or user', payload); + return; + } + + this.globalData.token = token; + this.globalData.userInfo = user; + + // Logic: + // 1. If it's a new user (is_new_user=True), go to Login Page to authorize. + // 2. If it's an existing user (is_new_user=False), stay at Home (or redirect there if not). + // Note: We ignore whether they have phone/nickname if they are existing users, per user request "direct login". + + if (is_new_user) { + wx.reLaunch({ + url: '/pages/login/login' + }) + } else { + // If we are currently on login page, go to home + // But onLaunch happens early. Usually we just don't redirect TO login. + // However, if the entry page WAS login (e.g. from share), we might want to go home. + // Or if the entry page is Home, we just stay there. + // Since we can't easily know "current page" in onLaunch without complex logic, + // and default entry is usually Home (pages/index/index), we just do nothing. + // BUT, if the app was configured to start at Login page in json, we might need to redirect. + // Let's assume default is index. + + // If we are explicitly on login page (re-launched or shared), we should leave. + const pages = getCurrentPages() + if (pages.length > 0) { + const route = pages[pages.length - 1].route + if (route.includes('pages/login/login')) { + wx.switchTab({ url: '/pages/index/index' }) + } + } + } + + // 如果有页面需要监听登录状态,可以在这里触发回调 + if (this.loginCallback) { + this.loginCallback(user); + } + } else { + console.log('登录失败!', resBody.msg || response.errMsg) + } + } + }) + } else { + console.log('登录失败!' + res.errMsg) + } + } + }) + } +}) diff --git a/wechat-mini-program/app.json b/wechat-mini-program/app.json new file mode 100644 index 0000000..5d46d4c --- /dev/null +++ b/wechat-mini-program/app.json @@ -0,0 +1,43 @@ +{ + "pages": [ + "pages/index/index", + "pages/course/course", + "pages/profile/profile", + "pages/coupon/coupon", + "pages/detail/detail", + "pages/login/login" + ], + "window": { + "backgroundTextStyle": "light", + "navigationBarBackgroundColor": "#fff", + "navigationBarTitleText": "Edu App", + "navigationBarTextStyle": "black" + }, + "style": "v2", + "sitemapLocation": "sitemap.json", + "tabBar": { + "color": "#999999", + "selectedColor": "#ff9900", + "backgroundColor": "#ffffff", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页", + "iconPath": "assets/tabbar/home.png", + "selectedIconPath": "assets/tabbar/home-active.png" + }, + { + "pagePath": "pages/course/course", + "text": "项目&活动", + "iconPath": "assets/tabbar/project.png", + "selectedIconPath": "assets/tabbar/project-active.png" + }, + { + "pagePath": "pages/profile/profile", + "text": "我的", + "iconPath": "assets/tabbar/user.png", + "selectedIconPath": "assets/tabbar/user-active.png" + } + ] + } +} diff --git a/wechat-mini-program/app.wxss b/wechat-mini-program/app.wxss new file mode 100644 index 0000000..3d1b8dc --- /dev/null +++ b/wechat-mini-program/app.wxss @@ -0,0 +1,45 @@ +/**app.wxss**/ +page { + /* Theme Colors */ + --primary-color: #ff9900; + --secondary-color: #f5a623; + --primary-gradient-start: #ff9900; + --primary-gradient-end: #f5a623; + --primary-light: #fff7e6; /* Light background for primary color */ + + /* Background Colors */ + --background-color: #f9fafb; + --surface-color: #ffffff; + --surface-hover: #f9fafb; + --surface-secondary: #f3f4f6; + + /* Text Colors */ + --text-main: #1f2937; + --text-secondary: #6b7280; + --text-light: #9ca3af; + --text-white: #ffffff; + + /* Border Colors */ + --border-color: #f3f4f6; + + /* Status Colors */ + --warning-color: #d97706; + --warning-bg: #fef3c7; + --success-color: #059669; + --success-bg: #ecfdf5; + --info-color: #2563eb; + --info-bg: #dbeafe; + --price-color: #e54d42; + + /* Variables usage */ + background-color: var(--background-color); + color: var(--text-main); +} + +.container { + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + diff --git a/wechat-mini-program/assets/empty-honor.png b/wechat-mini-program/assets/empty-honor.png new file mode 100644 index 0000000..c8bea93 Binary files /dev/null and b/wechat-mini-program/assets/empty-honor.png differ diff --git a/wechat-mini-program/assets/icons/coupon-icon.png b/wechat-mini-program/assets/icons/coupon-icon.png new file mode 100644 index 0000000..a5e978a Binary files /dev/null and b/wechat-mini-program/assets/icons/coupon-icon.png differ diff --git a/wechat-mini-program/assets/icons/profile-icon.png b/wechat-mini-program/assets/icons/profile-icon.png new file mode 100644 index 0000000..6de588b Binary files /dev/null and b/wechat-mini-program/assets/icons/profile-icon.png differ diff --git a/wechat-mini-program/assets/logo.png b/wechat-mini-program/assets/logo.png new file mode 100644 index 0000000..6e77f88 Binary files /dev/null and b/wechat-mini-program/assets/logo.png differ diff --git a/wechat-mini-program/assets/tabbar/coupon-active.png b/wechat-mini-program/assets/tabbar/coupon-active.png new file mode 100644 index 0000000..a5e978a Binary files /dev/null and b/wechat-mini-program/assets/tabbar/coupon-active.png differ diff --git a/wechat-mini-program/assets/tabbar/coupon.png b/wechat-mini-program/assets/tabbar/coupon.png new file mode 100644 index 0000000..e872b16 Binary files /dev/null and b/wechat-mini-program/assets/tabbar/coupon.png differ diff --git a/wechat-mini-program/assets/tabbar/home-active.png b/wechat-mini-program/assets/tabbar/home-active.png new file mode 100644 index 0000000..df27fb5 Binary files /dev/null and b/wechat-mini-program/assets/tabbar/home-active.png differ diff --git a/wechat-mini-program/assets/tabbar/home-active.svg b/wechat-mini-program/assets/tabbar/home-active.svg new file mode 100644 index 0000000..5824f44 --- /dev/null +++ b/wechat-mini-program/assets/tabbar/home-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wechat-mini-program/assets/tabbar/home.png b/wechat-mini-program/assets/tabbar/home.png new file mode 100644 index 0000000..e489c9b Binary files /dev/null and b/wechat-mini-program/assets/tabbar/home.png differ diff --git a/wechat-mini-program/assets/tabbar/home.svg b/wechat-mini-program/assets/tabbar/home.svg new file mode 100644 index 0000000..ed470cc --- /dev/null +++ b/wechat-mini-program/assets/tabbar/home.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wechat-mini-program/assets/tabbar/project-active.png b/wechat-mini-program/assets/tabbar/project-active.png new file mode 100644 index 0000000..aa99cf8 Binary files /dev/null and b/wechat-mini-program/assets/tabbar/project-active.png differ diff --git a/wechat-mini-program/assets/tabbar/project-active.svg b/wechat-mini-program/assets/tabbar/project-active.svg new file mode 100644 index 0000000..54fee9e --- /dev/null +++ b/wechat-mini-program/assets/tabbar/project-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wechat-mini-program/assets/tabbar/project.png b/wechat-mini-program/assets/tabbar/project.png new file mode 100644 index 0000000..1b26e15 Binary files /dev/null and b/wechat-mini-program/assets/tabbar/project.png differ diff --git a/wechat-mini-program/assets/tabbar/project.svg b/wechat-mini-program/assets/tabbar/project.svg new file mode 100644 index 0000000..24c1c45 --- /dev/null +++ b/wechat-mini-program/assets/tabbar/project.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wechat-mini-program/assets/tabbar/user-active.png b/wechat-mini-program/assets/tabbar/user-active.png new file mode 100644 index 0000000..29534f8 Binary files /dev/null and b/wechat-mini-program/assets/tabbar/user-active.png differ diff --git a/wechat-mini-program/assets/tabbar/user-active.svg b/wechat-mini-program/assets/tabbar/user-active.svg new file mode 100644 index 0000000..5c16e1b --- /dev/null +++ b/wechat-mini-program/assets/tabbar/user-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wechat-mini-program/assets/tabbar/user.png b/wechat-mini-program/assets/tabbar/user.png new file mode 100644 index 0000000..a112c71 Binary files /dev/null and b/wechat-mini-program/assets/tabbar/user.png differ diff --git a/wechat-mini-program/assets/tabbar/user.svg b/wechat-mini-program/assets/tabbar/user.svg new file mode 100644 index 0000000..b6a2302 --- /dev/null +++ b/wechat-mini-program/assets/tabbar/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wechat-mini-program/components/mp-html/.eslintignore b/wechat-mini-program/components/mp-html/.eslintignore new file mode 100644 index 0000000..121531a --- /dev/null +++ b/wechat-mini-program/components/mp-html/.eslintignore @@ -0,0 +1 @@ +*.min.js diff --git a/wechat-mini-program/components/mp-html/.eslintrc.json b/wechat-mini-program/components/mp-html/.eslintrc.json new file mode 100644 index 0000000..afc078a --- /dev/null +++ b/wechat-mini-program/components/mp-html/.eslintrc.json @@ -0,0 +1,30 @@ +{ + "env": { + "browser": true, + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:vue/essential" + ], + "globals": { + "Component": "readonly", + "wx": "readonly", + "qq": "readonly", + "swan": "readonly", + "my": "readonly", + "tt": "readonly", + "uni": "readonly", + "plus": "readonly", + "weex": "readonly", + "requirePlugin": "readonly" + }, + "rules": { + "semi": [ + "error", + "never" + ], + "no-console": "error" + } +} \ No newline at end of file diff --git a/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/bug.md b/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..f9beec5 --- /dev/null +++ b/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,23 @@ +--- +name: 提交 Bug +about: 如果发现某部分功能表现与文档描述不符或出错,请选择此模板反馈 +title: '' +labels: '' +assignees: '' + +--- + + + +## 使用环境 + + +## 问题描述 + + +## 复现方式 + diff --git a/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/enhancement.md b/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 0000000..08eecbb --- /dev/null +++ b/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,14 @@ +--- +name: 新功能需求 +about: 如果需要某些新功能或有改进建议,请选择此模板反馈 +title: '' +labels: '' +assignees: '' + +--- + + + +## 新功能描述 + +## 应用场景 diff --git a/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/question.md b/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..7ebcd80 --- /dev/null +++ b/wechat-mini-program/components/mp-html/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,12 @@ +--- +name: 咨询问题 +about: 如果找不到文档对某功能的描述或描述不清,请选择此模板咨询 +title: '' +labels: '' +assignees: '' + +--- + + + +## 问题描述 diff --git a/wechat-mini-program/components/mp-html/.stylelintrc.json b/wechat-mini-program/components/mp-html/.stylelintrc.json new file mode 100644 index 0000000..cb68cc1 --- /dev/null +++ b/wechat-mini-program/components/mp-html/.stylelintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "stylelint-config-standard", + "stylelint-config-recess-order" + ] +} \ No newline at end of file diff --git a/wechat-mini-program/components/mp-html/LICENSE b/wechat-mini-program/components/mp-html/LICENSE new file mode 100644 index 0000000..523073c --- /dev/null +++ b/wechat-mini-program/components/mp-html/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-present Jin Yufeng + +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/wechat-mini-program/components/mp-html/README.md b/wechat-mini-program/components/mp-html/README.md new file mode 100644 index 0000000..b6081ab --- /dev/null +++ b/wechat-mini-program/components/mp-html/README.md @@ -0,0 +1,236 @@ +# mp-html + +> 一个强大的小程序富文本组件 + +![star](https://img.shields.io/github/stars/jin-yufeng/mp-html) +![forks](https://img.shields.io/github/forks/jin-yufeng/mp-html) +[![npm](https://img.shields.io/npm/v/mp-html)](https://www.npmjs.com/package/mp-html) +![downloads](https://img.shields.io/npm/dt/mp-html) +[![Coverage Status](https://coveralls.io/repos/github/jin-yufeng/mp-html/badge.svg?branch=master)](https://coveralls.io/github/jin-yufeng/mp-html?branch=master) +![license](https://img.shields.io/github/license/jin-yufeng/mp-html) +[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) + +## 功能介绍 +- 支持在多个主流的小程序平台和 `uni-app` 中使用 +- 支持丰富的标签(包括 `table`、`video`、`svg` 等) +- 支持丰富的事件效果(自动预览图片、链接处理等) +- 支持设置占位图(加载中、出错时、预览时) +- 支持锚点跳转、长按复制等丰富功能 +- 支持大部分 *html* 实体 +- 丰富的插件(关键词搜索、内容编辑、`latex` 公式等) +- 效率高、容错性强且轻量化(`≈25KB`,`9KB gzipped`) + +查看 [功能介绍](https://jin-yufeng.github.io/mp-html/#/overview/feature) 了解更多 + +## 使用方法 +### 原生平台 +- `npm` 方式 + 1. 在项目目录下安装组件包 + + ```bash + npm install mp-html + ``` + 2. 开发者工具中勾选 `使用 npm 模块`(若没有此选项则不需要)并点击 `工具 - 构建 npm` + 3. 在需要使用页面的 `json` 文件中添加 + + ```json + { + "usingComponents": { + "mp-html": "mp-html" + } + } + ``` + 4. 在需要使用页面的 `wxml` 文件中添加 + + ```html + + ``` + 5. 在需要使用页面的 `js` 文件中添加 + + ```javascript + Page({ + onLoad () { + this.setData({ + html: '
Hello World!
' + }) + } + }) + ``` +- 源码方式 + 1. 将源码中对应平台的代码包(`dist/platform`)拷贝到 `components` 目录下,更名为 `mp-html` + 2. 在需要使用页面的 `json` 文件中添加 + + ```json + { + "usingComponents": { + "mp-html": "/components/mp-html/index" + } + } + ``` + + 后续步骤同上 + +查看 [快速开始](https://jin-yufeng.github.io/mp-html/#/overview/quickstart) 了解更多 + +### uni-app +- 源码方式 + 1. 将源码中 `dist/uni-app` 内的内容拷贝到项目根目录下 + 可以直接通过 [插件市场](https://ext.dcloud.net.cn/plugin?id=805) 引入 + 2. 在需要使用页面的 `vue` 文件中添加 + + ```vue + + + ``` +- `npm` 方式 + 1. 在项目目录下安装组件包 + + ```bash + npm install mp-html + ``` + 2. 在需要使用页面的 `vue` 文件中添加 + + ```vue + + + ``` + + 使用 `cli` 方式运行的项目,通过 `npm` 方式引入时,需要在 `vue.config.js` 中配置 `transpileDependencies`,详情可见 [#330](https://github.com/jin-yufeng/mp-html/issues/330#issuecomment-913617687) + 如果在 `nvue` 中使用还要将 `dist/uni-app/static` 目录下的内容拷贝到项目的 `static` 目录下,否则无法运行 + +查看 [快速开始](https://jin-yufeng.github.io/mp-html/#/overview/quickstart) 了解更多 + +## 组件属性 + +| 属性 | 类型 | 默认值 | 说明 | +|:---:|:---:|:---:|---| +| container-style | String | | 容器的样式([2.1.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v210)) | +| content | String | | 用于渲染的 html 字符串 | +| copy-link | Boolean | true | 是否允许外部链接被点击时自动复制 | +| domain | String | | 主域名(用于链接拼接) | +| error-img | String | | 图片出错时的占位图链接 | +| lazy-load | Boolean | false | 是否开启图片懒加载 | +| loading-img | String | | 图片加载过程中的占位图链接 | +| pause-video | Boolean | true | 是否在播放一个视频时自动暂停其他视频 | +| preview-img | Boolean | true | 是否允许图片被点击时自动预览 | +| scroll-table | Boolean | false | 是否给每个表格添加一个滚动层使其能单独横向滚动 | +| selectable | Boolean | false | 是否开启文本长按复制 | +| set-title | Boolean | true | 是否将 title 标签的内容设置到页面标题 | +| show-img-menu | Boolean | true | 是否允许图片被长按时显示菜单 | +| tag-style | Object | | 设置标签的默认样式 | +| use-anchor | Boolean | false | 是否使用锚点链接 | + +查看 [属性](https://jin-yufeng.github.io/mp-html/#/basic/prop) 了解更多 + +## 组件事件 + +| 名称 | 触发时机 | +|:---:|---| +| load | dom 树加载完毕时 | +| ready | 图片加载完毕时 | +| error | 发生渲染错误时 | +| imgtap | 图片被点击时 | +| linktap | 链接被点击时 | + +查看 [事件](https://jin-yufeng.github.io/mp-html/#/basic/event) 了解更多 + +## api +组件实例上提供了一些 `api` 方法可供调用 + +| 名称 | 作用 | +|:---:|---| +| in | 将锚点跳转的范围限定在一个 scroll-view 内 | +| navigateTo | 锚点跳转 | +| getText | 获取文本内容 | +| getRect | 获取富文本内容的位置和大小 | +| setContent | 设置富文本内容 | +| imgList | 获取所有图片的数组 | +| pauseMedia | 暂停播放音视频([2.2.2+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v222)) | +| setPlaybackRate | 设置音视频播放速率([2.4.0+](https://jin-yufeng.github.io/mp-html/#/changelog/changelog#v240)) | + +查看 [api](https://jin-yufeng.github.io/mp-html/#/advanced/api) 了解更多 + +## 插件扩展 +除基本功能外,本组件还提供了丰富的扩展,可按照需要选用 + +| 名称 | 作用 | +|:---:|---| +| audio | 音乐播放器 | +| editable | 富文本编辑 | +| emoji | 解析 emoji | +| highlight | 代码块高亮显示 | +| markdown | 渲染 markdown | +| search | 关键词搜索 | +| style | 匹配 style 标签中的样式 | +| txv-video | 使用腾讯视频 | +| img-cache | 图片缓存 by [@PentaTea](https://github.com/PentaTea) | +| latex | 渲染 latex 公式 by [@Zeng-J](https://github.com/Zeng-J) | +| card | 卡片展示 by [@whoooami](https://github.com/whoooami) | + +查看 [插件](https://jin-yufeng.github.io/mp-html/#/advanced/plugin) 了解更多 + +## 许可与支持 +- 许可 + 您可以免费的使用(包括商用)、复制或修改本组件 [MIT License](https://github.com/jin-yufeng/mp-html/blob/master/LICENSE) + 在用于生产环境前务必经过充分测试,由插件 `bug` 带来的损失概不负责(可以自行修改源码) + + +## 更新日志 +- v2.5.1 (20250420) + 1. `U` `uni-app` 包适配鸿蒙 `APP` [详细](https://github.com/jin-yufeng/mp-html/issues/615) + 2. `U` 微信小程序替换废弃 `api` `getSystemInfoSync` [详细](https://github.com/jin-yufeng/mp-html/issues/613) + 3. `F` 修复了微信小程序 `glass-easel` 框架下真机换行异常的问题 [详细](https://github.com/jin-yufeng/mp-html/pull/607) by [@PaperStrike](https://github.com/PaperStrike) + 4. `F` 修复了 `uni-app` 包 `app` 端播放视频可能报错的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/617) + 5. `F` 修复了 `latex` 插件可能出现 `xxx can be used only in display mode` 的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/632) + 6. `F` 修复了 `uni-app` 包 `latex` 公式可能不显示的问题 [#599](https://github.com/jin-yufeng/mp-html/issues/599)、[#627](https://github.com/jin-yufeng/mp-html/issues/627) + +- v2.5.0 (20240422) + 1. `U` `play` 事件增加返回 `src` 等信息 [详细](https://github.com/jin-yufeng/mp-html/issues/526) + 2. `U` `preview-img` 属性支持设置为 `all` 开启 `base64` 图片预览 [详细](https://github.com/jin-yufeng/mp-html/issues/536) + 3. `U` `editable` 插件增加简易模式(点击文字直接编辑) + 4. `U` `latex` 插件支持块级公式 [详细](https://github.com/jin-yufeng/mp-html/issues/582) + 5. `F` 修复了表格部分情况下背景丢失的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/587) + 6. `F` 修复了部分 `svg` 无法显示的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/591) + 7. `F` 修复了 `uni-app` 包 `h5` 和 `app` 端部分情况下样式无法识别的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/518) + 8. `F` 修复了 `latex` 插件部分情况下显示不正确的问题 [详细](https://github.com/jin-yufeng/mp-html/issues/580) + 9. `F` 修复了 `editable` 插件表格无法删除的问题 + 10. `F` 修复了 `editable` 插件 `uni-app` 包 `vue3` `h5` 端点击图片报错的问题 + 11. `F` 修复了 `editable` 插件 `uni-app` 包点击表格没有菜单栏的问题 + + 从 `1.x` 的升级方法可见 [更新指南](https://jin-yufeng.github.io/mp-html/#/changelog/changelog?id=v200) + +查看 [更新日志](https://jin-yufeng.github.io/mp-html/#/changelog/changelog) 了解更多 diff --git a/wechat-mini-program/components/mp-html/dist/mp-alipay/index.acss b/wechat-mini-program/components/mp-html/dist/mp-alipay/index.acss new file mode 100644 index 0000000..5f74d06 --- /dev/null +++ b/wechat-mini-program/components/mp-html/dist/mp-alipay/index.acss @@ -0,0 +1 @@ +._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text} \ No newline at end of file diff --git a/wechat-mini-program/components/mp-html/dist/mp-alipay/index.axml b/wechat-mini-program/components/mp-html/dist/mp-alipay/index.axml new file mode 100644 index 0000000..318219b --- /dev/null +++ b/wechat-mini-program/components/mp-html/dist/mp-alipay/index.axml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wechat-mini-program/components/mp-html/dist/mp-alipay/index.js b/wechat-mini-program/components/mp-html/dist/mp-alipay/index.js new file mode 100644 index 0000000..1e2fdad --- /dev/null +++ b/wechat-mini-program/components/mp-html/dist/mp-alipay/index.js @@ -0,0 +1,8 @@ +"use strict";function e(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}/*! + * mp-html v2.5.1 + * https://github.com/jin-yufeng/mp-html + * + * Released under the MIT license + * Author: Jin Yufeng + */ +var t=require("./parser"),n=[];Component({data:{nodes:[]},props:{containerStyle:"",content:"",copyLink:!0,domain:"",errorImg:"",lazyLoad:!1,loadingImg:"",pauseVideo:!0,previewImg:!0,scrollTable:!1,setTitle:!0,showImgMenu:!0,tagStyle:{}},didMount:function(){this.plugins=[];for(var e=n.length;e--;)this.plugins.push(new n[e](this));this.props.content&&this.setContent(this.props.content)},didUpdate:function(e){e.content!==this.props.content&&this.setContent(this.props.content)},didUnmount:function(){this._hook("onDetached")},methods:{in:function(e,t,n){e&&t&&n&&(this._in={page:e,selector:t,scrollTop:n})},navigateTo:function(t,n){var i=this;return new Promise(function(o,r){if(!i.props.useAnchor)return void r(Error("Anchor is disabled"));var s=my.createSelectorQuery().select((i._in?i._in.selector:"._root")+(t?"".concat(" ","#").concat(t):"")).boundingClientRect();i._in?s.select(i._in.selector).scrollOffset().select(i._in.selector).boundingClientRect():s.selectViewport().scrollOffset(),s.exec(function(t){if(!t[0])return void r(Error("Label not found"));var s=t[1].scrollTop+t[0].top-(t[2]?t[2].top:0)+(n||parseInt(i.props.useAnchor)||0);i._in?i._in.page.setData(e({},i._in.scrollTop,s)):my.pageScrollTo({scrollTop:s,duration:300}),o()})})},getText:function(e){var t="";return function e(n){for(var i=0;i"0"&&o.name[1]<"7";r&&t&&"\n"!==t[t.length-1]&&(t+="\n"),o.children&&e(o.children),r&&"\n"!==t[t.length-1]?t+="\n":"td"!==o.name&&"th"!==o.name||(t+="\t")}}}(e||this.data.nodes),t},getRect:function(){return new Promise(function(e,t){my.createSelectorQuery().select("._root").boundingClientRect().exec(function(n){return n[0]?e(n[0]):t(Error("Root label not found"))})})},pauseMedia:function(){for(var e=(this._videos||[]).length;e--;)this._videos[e].pause()},setPlaybackRate:function(e){this.playbackRate=e;for(var t=(this._videos||[]).length;t--;)this._videos[t].playbackRate(e)},setContent:function(e,n){var i=this;this.imgList&&n||(this.imgList=[]),this._videos=[];var o={},r=new t(this).parse(e);if(n)for(var s=this.data.nodes.length,a=r.length;a--;)o["nodes[".concat(s+a,"]")]=r[a];else o.nodes=r;if(this.setData(o,function(){i._hook("onLoad"),i.props.onLoad&&i.props.onLoad()}),this.props.lazyLoad||this.imgList._unloadimgs{{'\n'}}